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/.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/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 fa42372ac81..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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + 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/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c4c1ab072ff..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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + 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..5bcdb22cb86 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 @@ -219,5 +219,6 @@ jobs: run: | docker run --rm \ -v "${{ github.workspace }}/docker/test_configs:/config" \ + -e ESPHOME_LDGEN_STRICT=1 \ "ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \ compile "${{ matrix.id }}.yaml" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 689baa1292e..5eb96af59b6 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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + 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 @@ -268,7 +299,7 @@ jobs: # Pushes any fixes the hooks made back to the pull request. This step # must keep its default name: the GitHub App that performs the push # locates the workflow run by that name. - - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 + - uses: pre-commit-ci/lite-action@062bca0919bc9d6e66755cc05074b70c77e111fc # v1.2.0 if: always() with: msg: apply automatic formatting fixes @@ -321,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 @@ -350,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 @@ -369,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 @@ -396,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 @@ -413,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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -471,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 @@ -601,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 + uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 with: run: | . venv/bin/activate @@ -1097,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 @@ -1195,9 +1225,28 @@ 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" + # The bootloader has no ESPHome code and these builds never + # flash; the check_idf_py batch keeps the full build so the + # native sub-build and the equivalence check stay covered. + skip_flag="--skip-bootloader" + if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then + skip_flag="" + fi + python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" $skip_flag + 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 @@ -1237,7 +1286,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..." @@ -1246,6 +1295,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 @@ -1274,7 +1357,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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1415,12 +1498,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' @@ -1505,11 +1585,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: | @@ -1608,6 +1686,7 @@ jobs: needs: - common - seed-apt-cache + - seed-esp8266-native-cache - determine-jobs - ci-custom - pylint @@ -1623,6 +1702,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 0daae69ccf0..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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + 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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 104988d7a58..bfe96f3944a 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -125,14 +125,20 @@ jobs: } async function getEsphomeAndComponentChanges(github, owner, repo, prNumber) { - const changedFiles = await github.rest.pulls.listFiles({ - owner: owner, - repo: repo, - pull_number: prNumber, - }); + const changedFiles = await github.paginate( + github.rest.pulls.listFiles, + { + owner: owner, + repo: repo, + pull_number: prNumber, + per_page: 100, + } + ); - const esphomeChanges = changedFiles.data - .filter(file => file.filename !== "esphome/core/defines.h" && file.filename.startsWith('esphome/')) + // Files used only for development and CI, which do not affect use as an external component + const ignoredFiles = ["esphome/core/defines.h", "esphome/idf_component.yml"]; + const esphomeChanges = changedFiles + .filter(file => !ignoredFiles.includes(file.filename) && file.filename.startsWith('esphome/')) .map(file => { const match = file.filename.match(/esphome\/([^/]+)/); return match ? match[1] : null; @@ -144,7 +150,7 @@ jobs: } const uniqueEsphomeChanges = [...new Set(esphomeChanges)]; - const componentChanges = changedFiles.data + const componentChanges = changedFiles .filter(file => file.filename.startsWith('esphome/components/')) .map(file => { const match = file.filename.match(/esphome\/components\/([^/]+)\//); 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 38d2418ac62..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: diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 72987c25b16..5f0966c1515 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -7,10 +7,6 @@ on: permissions: pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr) -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - jobs: check: name: Check blocking labels diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 84d5e229d91..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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + 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 0c7600ec127..727e8e2f9aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.7 + rev: v0.16.10 hooks: # Run the linter. - id: ruff @@ -18,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: diff --git a/AGENTS.md b/AGENTS.md index 448bf49114f..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). @@ -443,6 +462,20 @@ file does, and it is the authority when they disagree. The most useful starting 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 template class MyCondition : public Condition { @@ -456,6 +489,19 @@ file does, and it is the authority when they disagree. The most useful starting 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`. * **Configuration Validation:** @@ -710,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) diff --git a/CODEOWNERS b/CODEOWNERS index 685d2aa4e4b..53156aae995 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -125,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 @@ -186,10 +187,12 @@ esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core esphome/components/esp_ldo/* @clydebarrow +esphome/components/espectre/* @francescopace esphome/components/espnow/* @jesserockz esphome/components/espnow/packet_transport/* @EasilyBoredEngineer esphome/components/ethernet_info/* @gtjadsonsantos esphome/components/event/* @nohat +esphome/components/exponential_moving_average/* @clydebarrow esphome/components/exposure_notifications/* @OttoWinter esphome/components/ezo/* @ssieb esphome/components/ezo_pmp/* @carlos-sarmiento @@ -244,8 +247,11 @@ esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2 esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter +esphome/components/homeassistant/button/* @jesserockz esphome/components/homeassistant/number/* @landonr +esphome/components/homeassistant/select/* @jesserockz esphome/components/homeassistant/switch/* @Links2004 +esphome/components/homeassistant/text/* @jesserockz esphome/components/honeywell_hih_i2c/* @Benichou34 esphome/components/honeywellabp/* @RubyBailey esphome/components/honeywellabp2_i2c/* @jpfaff @@ -281,6 +287,7 @@ esphome/components/inkplate/* @jesserockz @JosipKuci esphome/components/integration/* @OttoWinter esphome/components/internal_temperature/* @Mat931 esphome/components/interval/* @esphome/core +esphome/components/ir_rf_base/* @bdraco @kbx81 esphome/components/ir_rf_proxy/* @kbx81 esphome/components/it8951/* @koosoli @limengdu @Passific esphome/components/jsn_sr04t/* @Mafus1 @@ -422,6 +429,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 @@ -429,6 +437,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 @@ -457,6 +466,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 @@ -537,6 +547,7 @@ esphome/components/st7735/* @SenexCrenshaw esphome/components/st7789v/* @kbx81 esphome/components/st7920/* @marsjan155 esphome/components/statsd/* @Links2004 +esphome/components/stcc4/* @j9brown esphome/components/store_yaml/* @bdraco esphome/components/stts22h/* @B48D81EFCC esphome/components/substitutions/* @esphome/core @@ -548,11 +559,15 @@ 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/tas58xx/* @mrtoy-me @remcom esphome/components/tc74/* @sethgirvan esphome/components/tca9548a/* @andreashergert1984 esphome/components/tca9555/* @mobrembski esphome/components/tcl112/* @glmnet +esphome/components/tcp_uart/* @Bascht74 esphome/components/tee501/* @Stock-M esphome/components/teleinfo/* @0hax esphome/components/tem3200/* @bakerkj @@ -561,6 +576,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 @@ -593,11 +609,13 @@ esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd esphome/components/uart/packet_transport/* @clydebarrow esphome/components/uart_mux/* @kbx81 +esphome/components/uart_tcp/* @Bascht74 esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli esphome/components/ufm01/* @ljungqvist esphome/components/ultrasonic/* @ssieb @swoboda1337 +esphome/components/unicode/* @esphome/core esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon esphome/components/usb_cdc_acm/* @kbx81 @@ -637,9 +655,11 @@ 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 +esphome/components/xiaomi_mccgq02hl/* @ahpohl @morph027 esphome/components/xiaomi_mhoc303/* @drug123 esphome/components/xiaomi_mhoc401/* @vevsvevs esphome/components/xiaomi_rtcgq02lm/* @jesserockz diff --git a/docker/Dockerfile b/docker/Dockerfile index e00570c8ff2..9f29062e093 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.9 +RUN uv pip install --no-cache-dir esphome-device-builder==1.21.0 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..6c984050f42 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 @@ -149,6 +148,7 @@ class ArgsProtocol(Protocol): file: str | None no_logs: bool only_generate: bool + skip_bootloader: bool show_secrets: bool dashboard: bool configuration: str @@ -817,7 +817,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() @@ -826,6 +828,14 @@ def write_cpp_file() -> int: def compile_program(args: ArgsProtocol, config: ConfigType) -> int: + if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf): + # Debug only: an orchestrator cannot see YAML toolchain overrides, + # so this is its expected no-op, and a full build is safe. + _LOGGER.debug( + "--skip-bootloader ignored: only supported on ESP32 with the " + "esp-idf toolchain" + ) + CORE.skip_bootloader = False # Keep this gate here, NOT in config validation: device-builder needs # `esphome config` to keep succeeding with placeholders so onboarding can run. if CONF_WIFI in config: @@ -835,7 +845,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() @@ -856,23 +866,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int: return rc # Create factory.bin, ota.bin, and firmware.elf copy - toolchain.create_factory_bin() + if not toolchain.create_factory_bin(): + # A build whose factory image could not be produced must not + # exit 0; downloads would serve an image from an older build. + return 1 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 +982,16 @@ 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(): + hint = getattr(native, "missing_image_hint", lambda: None)() + raise EsphomeError( + hint or f"{image} does not exist; compile the configuration first" + ) + flash_images = [FlashImage(path=image, offset="0x0")] else: from esphome.platformio import toolchain @@ -1289,10 +1300,9 @@ def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: The native API uses challenge-response auth with MD5/SHA256 hashing of a server-issued nonce, so the password is never sent over the wire; the ``web_server`` path uses HTTP Basic auth which transmits credentials in - cleartext over the LAN. (The native path also supports gzip compression - on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the - backend reports ``supports_compression() == false`` and the firmware is - sent uncompressed regardless of which platform is used.) Falls back to + cleartext over the LAN. (The native path also compresses the upload: + gzip on ESP8266 and RP2040, which inflate it at reboot, and a deflate + stream on ESP32/LibreTiny, which inflate it as it arrives.) Falls back to ``web_server`` only when that is the only available platform. """ # Use a dict (insertion-ordered) instead of a list so error messages and @@ -1343,8 +1353,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; " @@ -1377,6 +1391,12 @@ def _upload_via_native_api( ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE elif getattr(args, "bootloader", False): check_partition_access("--bootloader") + if ( + getattr(args, "file", None) is None + and (native := native_backend()) + and (hint := getattr(native, "missing_image_hint", lambda: None)()) + ): + raise EsphomeError(hint) binary = CORE.bootloader_bin ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER if getattr(args, "file", None) is not None: @@ -1395,6 +1415,7 @@ def _upload_via_native_api( ota_type, noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, ) @@ -1709,26 +1730,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( @@ -1765,6 +1772,18 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: + if ( + CORE.skip_bootloader + and CORE.is_esp32 + and CORE.using_toolchain_esp_idf + and any( + get_port_type(device) == PortType.SERIAL for device in (args.device or []) + ) + ): + # Fail before the compile: the result could never flash over serial. + # Elsewhere the flag is ignored, so serial stays fine there. + _LOGGER.error("--skip-bootloader builds cannot be flashed over serial") + return 1 exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -1773,7 +1792,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 +1984,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 +2028,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 +2055,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 +2132,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 = { @@ -2263,6 +2169,15 @@ SIMPLE_CONFIG_ACTIONS = [ ] +def _add_skip_bootloader_arg(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--skip-bootloader", + help="Do not build the bootloader or the factory image; " + "the result can only be flashed over OTA.", + action="store_true", + ) + + def _add_states_args(parser: argparse.ArgumentParser) -> None: """Add mutually exclusive ``--states``/``--no-states`` flags to a parser. @@ -2343,7 +2258,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}." ), ) @@ -2403,6 +2319,7 @@ def parse_args(argv): help="Only generate source code, do not compile.", action="store_true", ) + _add_skip_bootloader_arg(parser_compile) parser_upload = subparsers.add_parser( "upload", @@ -2466,7 +2383,7 @@ def parse_args(argv): "-r", action="store_true", help="Reset the device before starting serial logs.", - default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), + default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"), ) _add_states_args(parser_logs) @@ -2499,6 +2416,7 @@ def parse_args(argv): parser_run.add_argument( "--no-logs", help="Disable starting logs.", action="store_true" ) + _add_skip_bootloader_arg(parser_run) _add_states_args(parser_run) @@ -2507,7 +2425,7 @@ def parse_args(argv): "-r", action="store_true", help="Reset the device before starting serial logs.", - default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"), + default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"), ) parser_run.add_argument( "--ota-platform", @@ -2758,6 +2676,7 @@ def run_esphome(argv): CORE.config_path = conf_path CORE.dashboard = args.dashboard + CORE.skip_bootloader = getattr(args, "skip_bootloader", False) if args.toolchain is not None: # CLI toolchain wins over esp32.toolchain in YAML. CORE.toolchain = args.toolchain 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/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 3ffda50c812..b69edd04a13 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,18 +21,46 @@ from esphome.const import ( CONF_TYPE_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID, Lambda +from esphome.core import CORE, ID, EsphomeError, HexInt, 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 +def progmem_bytes(name: str, data: bytes | list[int]) -> MockObj: + """Shared PROGMEM table for constant bytes; equal payloads share one, empty is nullptr.""" + if not data: + return cg.nullptr + return cg.shared_progmem_array( + name, cg.uint8, cg.ArrayInitializer(*(HexInt(x) for x in data)) + ) + + +async def templatable_bytes( + value: Any, + args: TemplateArgsType, + set_template: MockObj, + set_static: MockObj, + table_name: str, +) -> None: + """Set a TemplatableBytes: a lambda via set_template, constant bytes via set_static.""" + if cg.is_template(value): + fn = await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + cg.add(set_template(fn)) + elif len(value) > 0xFFFF: + raise EsphomeError(f"Byte payload is {len(value)} bytes; the maximum is 65535") + else: + cg.add(set_static(progmem_bytes(table_name, value), len(value))) + + def maybe_simple_id(*validators): """Allow a raw ID to be specified in place of a config block. If the value that's being validated is a dictionary, it's passed as-is to the specified validators. Otherwise, it's @@ -57,6 +88,7 @@ def maybe_conf(conf, *validators): with cv.remove_prepend_path([conf]): return validator({conf: value}) + validate.inner_schema = validator return validate @@ -207,6 +239,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): @@ -454,28 +788,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( diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py new file mode 100644 index 00000000000..5b14af5a1d7 --- /dev/null +++ b/esphome/build_gen/arduino8266.py @@ -0,0 +1,1216 @@ +"""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 follows PlatformIO: ldscript filename first, then + # upload.maximum_size, so the header always matches the layout. + # -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 {_elf2bin_flash_size(board, flash_ld_name)} --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" + + +# Same pattern PlatformIO's _get_flash_size applies to the ldscript path +_LD_FLASH_SIZE_RE = re.compile(r"\.flash\.(\d+[mk]).*\.ld") +# The framework elf2bin.py's --flash_size choices +_ELF2BIN_FLASH_SIZES = frozenset({"256K", "512K", "1M", "2M", "4M", "8M", "16M"}) + + +def _elf2bin_flash_size(board: str, flash_ld_name: str) -> str: + """Image-header flash size as PlatformIO derives it: ldscript filename, + else board_upload.maximum_size, else the board table. The SDK clamps the + chip to the header size at boot, so a header smaller than the linked + layout breaks OTA writes on the running device.""" + if match := _LD_FLASH_SIZE_RE.search(flash_ld_name): + token = match.group(1) + base = 1024 if token[-1] == "k" else 1024 * 1024 + return _flash_size_str(int(token[:-1]) * base) + if max_size := _pio_option("board_upload.maximum_size", ""): + if ( + not max_size.isdigit() + or (size := _flash_size_str(int(max_size))) not in _ELF2BIN_FLASH_SIZES + ): + raise EsphomeError(f"Invalid board_upload.maximum_size value {max_size!r}") + return size + return _flash_size_str(BOARDS[board][KEY_FLASH_SIZE]) diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 7689fc93b0e..e7a2dc1536b 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -3,7 +3,10 @@ import json import logging from pathlib import Path +import re +import textwrap +from esphome.build_helpers import pch from esphome.components.esp32 import ( get_esp32_variant, get_excluded_builtin_components, @@ -18,7 +21,7 @@ from esphome.framework_helpers import ( get_project_cxx_compile_flags, get_project_link_flags, ) -from esphome.helpers import mkdir_p, write_file_if_changed +from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -33,6 +36,83 @@ list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=") list(APPEND esphome_cxx_compile_options "-std={standard}") idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""" +# Drops the app archive from ldgen's inputs so app-only edits skip the +# sections.ld regeneration. Safe: no mapping fragment references it +# (run_compile re-checks each build). Filters only the top-level call; +# the prior definition stays reachable with an underscore prefix. +_LDGEN_OVERRIDE = """\ +if(COMMAND __ldgen_get_lib_deps_of_target) + set_property(GLOBAL PROPERTY ESPHOME_LDGEN_ARMED 1) + function(__ldgen_get_lib_deps_of_target target out_list_var) + if(NOT COMMAND ___ldgen_get_lib_deps_of_target) + message(FATAL_ERROR "ESPHome ldgen override lost the original " + "implementation; set ESPHOME_LDGEN_FULL_DEPS=1 and rebuild.") + endif() + ___ldgen_get_lib_deps_of_target(${target} ${out_list_var}) + if(out_list_var STREQUAL "ldgen_libraries") + set_property(GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED 1) + list(LENGTH ${out_list_var} esphome_ldgen_before) + list(REMOVE_ITEM ${out_list_var} idf::src __idf_src) + list(LENGTH ${out_list_var} esphome_ldgen_after) + if(esphome_ldgen_before EQUAL esphome_ldgen_after) + message(@SEVERITY@ "ESPHome ldgen app archive exclusion matched " + "nothing; app edits will regenerate sections.ld.") + endif() + endif() + set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE) + endfunction() +else() + message(@MISSING@ "ESPHome ldgen override target not found; " + "app edits will regenerate sections.ld.") +endif()""" + +# lwip sources that compile to empty objects with the option off (their own +# #if guard). (option, regex valid for both Python and CMake); a source is +# only dropped when its option is defined and off, so a renamed option +# keeps it. +LWIP_EMPTY_SOURCES: tuple[tuple[str, str], ...] = ( + ("CONFIG_LWIP_PPP_SUPPORT", "/netif/ppp/"), + ("CONFIG_LWIP_IPV6", "/core/ipv6/"), + ("CONFIG_LWIP_AUTOIP", "/core/ipv4/autoip[.]c$"), + ("CONFIG_LWIP_STATS", "/core/stats[.]c$"), +) +# Drift guard only: keep every lwip source. +LWIP_FULL_SOURCES_ENV = "ESPHOME_LWIP_FULL_SOURCES" + +# Drops the empty objects after project(), once the lwip target exists. +_LWIP_EMPTY_SOURCES_FILTER = f"""\ +idf_build_get_property(esphome_build_components BUILD_COMPONENTS) +if(lwip IN_LIST esphome_build_components AND NOT DEFINED ENV{{{LWIP_FULL_SOURCES_ENV}}}) + idf_component_get_property(esphome_lwip_lib lwip COMPONENT_LIB) + get_target_property(esphome_lwip_srcs ${{esphome_lwip_lib}} SOURCES) +@FILTERS@ + set_property(TARGET ${{esphome_lwip_lib}} PROPERTY SOURCES ${{esphome_lwip_srcs}}) +endif()""" + + +def lwip_empty_source_gate(option: str, regex: str) -> str: + return ( + f" if(DEFINED {option} AND NOT {option})\n" + f' list(FILTER esphome_lwip_srcs EXCLUDE REGEX "{regex}")\n' + " endif()" + ) + + +def _lwip_empty_sources_filter() -> str: + gates = "\n".join(lwip_empty_source_gate(*entry) for entry in LWIP_EMPTY_SOURCES) + return _LWIP_EMPTY_SOURCES_FILTER.replace("@FILTERS@", gates) + + +# Runs after project() so the walk has happened; catches the remaining +# silent path where the top-level out-var was renamed. +_LDGEN_OVERRIDE_CHECK = """\ +get_property(esphome_ldgen_armed GLOBAL PROPERTY ESPHOME_LDGEN_ARMED) +get_property(esphome_ldgen_filtered GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED) +if(esphome_ldgen_armed AND NOT esphome_ldgen_filtered) + message(@SEVERITY@ "ESPHome ldgen override never filtered the app " + "archive; app edits will regenerate sections.ld.") +endif()""" + def get_available_components() -> list[str] | None: """List the built-in ESP-IDF components from ``project_description.json``. @@ -78,6 +158,85 @@ def _cmake_quote(value: str) -> str: return f'"{escaped}"' +# CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be +# cleared at the CMake level (the same state IDF's RAM-app build type uses). +# The macro is IDF's __build_process_project_includes plus a few added lines; +# the flag is ignored and the bootloader builds as usual if IDF changes it. +IDF_BOOTLOADER_OVERRIDE = """\ +# ESPHome bootloader skip switch; see esphome/espidf/toolchain.py. +if(ESPHOME_SKIP_BOOTLOADER) + macro(__build_process_project_includes) + idf_build_get_property(sdkconfig_cmake SDKCONFIG_CMAKE) + include(${sdkconfig_cmake}) + set(CONFIG_APP_BUILD_BOOTLOADER "") + # bt's CMakeLists reads the lowercase idf_target that the (now + # skipped) bootloader project_include leaks; keep it defined, or + # its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /. + idf_build_get_property(idf_target IDF_TARGET) + # partition_table's V1 ECDSA signing reads this key, which the + # skipped bootloader project_include also sets. + get_filename_component(SECURE_BOOT_SIGNING_KEY "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}") + idf_build_get_property(build_properties __BUILD_PROPERTIES) + foreach(build_property ${build_properties}) + idf_build_get_property(val ${build_property}) + set(${build_property} "${val}") + endforeach() + idf_build_get_property(build_component_targets __BUILD_COMPONENT_TARGETS) + foreach(component_target ${build_component_targets}) + __component_get_property(dir ${component_target} COMPONENT_DIR) + __component_get_property(_name ${component_target} COMPONENT_NAME) + set(COMPONENT_NAME ${_name}) + set(COMPONENT_DIR ${dir}) + set(COMPONENT_PATH ${dir}) + if(EXISTS ${COMPONENT_DIR}/project_include.cmake) + include(${COMPONENT_DIR}/project_include.cmake) + endif() + endforeach() + endmacro() +endif() +""" + +# The lines the override adds to IDF's macro; idf_macro_matches() below +# strips them before comparing with the live macro. +BOOTLOADER_OVERRIDE_ADDED_LINES = ( + 'set(CONFIG_APP_BUILD_BOOTLOADER "")', + "idf_build_get_property(idf_target IDF_TARGET)", + ( + "get_filename_component(SECURE_BOOT_SIGNING_KEY" + ' "${CONFIG_SECURE_BOOT_SIGNING_KEY}" ABSOLUTE BASE_DIR "${project_dir}")' + ), +) + +_MACRO = re.compile( + r"macro\(__build_process_project_includes\)(.*?)endmacro\(\)", re.DOTALL +) + + +def _normalized_macro(text: str) -> list[str] | None: + """The macro body as comment-free, whitespace-collapsed lines.""" + if (match := _MACRO.search(text)) is None: + return None + return [ + re.sub(r"\s+", " ", line) + for raw in match.group(1).splitlines() + if (line := raw.split("#", 1)[0].strip()) + ] + + +_EXPECTED_MACRO = [ + line + for line in _normalized_macro(IDF_BOOTLOADER_OVERRIDE) + if line not in BOOTLOADER_OVERRIDE_ADDED_LINES +] + + +def idf_macro_matches(idf_path: Path) -> bool: + """Whether IDF's macro still matches the copy the override replays.""" + build_cmake = idf_path / "tools" / "cmake" / "build.cmake" + live = _normalized_macro(build_cmake.read_text(encoding="utf-8")) + return live == _EXPECTED_MACRO + + def get_project_cmakelists( minimal: bool = False, builtin_components: list[str] | None = None ) -> str: @@ -123,6 +282,22 @@ def get_project_cmakelists( else "" ) + # Stops the ~3s sections.ld regeneration on app-only edits; see + # _LDGEN_OVERRIDE. ESPHOME_LDGEN_FULL_DEPS=1 restores stock behavior; + # ESPHOME_LDGEN_STRICT=1 (CI) fails the configure when an IDF bump + # breaks the override instead of degrading to stock deps. + if get_bool_env("ESPHOME_LDGEN_FULL_DEPS"): + ldgen_override = "" + ldgen_override_check = "" + else: + strict = get_bool_env("ESPHOME_LDGEN_STRICT") + severity = "FATAL_ERROR" if strict else "WARNING" + missing = "FATAL_ERROR" if strict else "STATUS" + ldgen_override = _LDGEN_OVERRIDE.replace("@SEVERITY@", severity).replace( + "@MISSING@", missing + ) + ldgen_override_check = _LDGEN_OVERRIDE_CHECK.replace("@SEVERITY@", severity) + # CMake variables registered via cg.add_cmake_arg(). Emitted before # include(project.cmake) so values like EXCLUDE_COMPONENTS are already # set when project.cmake seeds the component list, and on minimal @@ -200,6 +375,9 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{IDF_BOOTLOADER_OVERRIDE} +{ldgen_override} + {cpp_standard_options} {cxx_compile_options} @@ -212,14 +390,22 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) project({CORE.name}) +{ldgen_override_check} + +{_lwip_empty_sources_filter()} + # 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. +# The map is a BYPRODUCT so ninja knows the link writes it; IDF's size +# target depends on the map and can then be built in the same run as all. +# IDF's cmakev2 declares the map itself, so drop this line on that switch. add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2 -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map + BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} VERBATIM ) @@ -282,9 +468,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..e7c08048b9f 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -31,6 +31,7 @@ from esphome.cpp_generator import ( # noqa: F401 add_global, add_library, add_platformio_option, + extern_progmem_array, get_variable, get_variable_with_full_id, is_template, @@ -40,8 +41,10 @@ from esphome.cpp_generator import ( # noqa: F401 progmem_array, safe_exp, set_cpp_standard, + shared_progmem_array, statement, static_const_array, + static_function, templatable, variable, with_local_variable, @@ -64,7 +67,6 @@ from esphome.cpp_types import ( # noqa: F401 Application, Component, ComponentPtr, - Controller, EntityBase, EntityCategory, ESPTime, 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..d20d80b2fc7 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( { @@ -70,16 +62,10 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - sens = await sensor.new_sensor(config[CONF_TVOC]) - cg.add(var.set_tvoc(sens)) - - if version_config := config.get(CONF_VERSION): - sens = await sensor.new_sensor(version_config) - cg.add(var.set_version(sens)) - - if resistance_config := config.get(CONF_RESISTANCE): - sens = await sensor.new_sensor(resistance_config) - cg.add(var.set_resistance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TVOC, var.set_tvoc) + await sensors(CONF_VERSION, var.set_version) + await sensors(CONF_RESISTANCE, var.set_resistance) AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value( @@ -91,24 +77,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 +101,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/aht10/sensor.py b/esphome/components/aht10/sensor.py index ae669d00004..add86e16a63 100644 --- a/esphome/components/aht10/sensor.py +++ b/esphome/components/aht10/sensor.py @@ -57,10 +57,6 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_variant(config[CONF_VARIANT])) - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature_sensor(sens)) - - if humidity := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) 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 aced89b7ffc..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; 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/am2315c/sensor.py b/esphome/components/am2315c/sensor.py index febb11409c5..39ded956c3f 100644 --- a/esphome/components/am2315c/sensor.py +++ b/esphome/components/am2315c/sensor.py @@ -46,10 +46,6 @@ 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)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/components/am2320/sensor.py b/esphome/components/am2320/sensor.py index ffac0e64077..30a1b1d8a34 100644 --- a/esphome/components/am2320/sensor.py +++ b/esphome/components/am2320/sensor.py @@ -48,10 +48,6 @@ 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)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/components/am43/sensor/__init__.py b/esphome/components/am43/sensor/__init__.py index 80341972a95..ef746f3e52f 100644 --- a/esphome/components/am43/sensor/__init__.py +++ b/esphome/components/am43/sensor/__init__.py @@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await ble_client.register_ble_node(var, config) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery(sens)) - - if illuminance_config := config.get(CONF_ILLUMINANCE): - sens = await sensor.new_sensor(illuminance_config) - cg.add(var.set_illuminance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_LEVEL, var.set_battery) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) 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/api/__init__.py b/esphome/components/api/__init__.py index 854bceecfad..5e68e200312 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,3 +1,4 @@ +from ipaddress import IPv4Address, IPv6Address import logging import re from typing import Any @@ -5,7 +6,7 @@ from typing import Any from esphome import automation from esphome.automation import Condition import esphome.codegen as cg -from esphome.components.const import CONF_DESCRIPTION +from esphome.components.const import CONF_DESCRIPTION, CONF_HOST from esphome.components.logger import request_log_listener # ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external @@ -13,6 +14,7 @@ from esphome.components.logger import request_log_listener from esphome.components.noise import ( # noqa: F401 ENCRYPTION_SCHEMA, decode_encryption_key, + enable_spare_ephemeral, encryption_schema, new_psk_progmem, validate_encryption_key, @@ -25,6 +27,8 @@ from esphome.const import ( CONF_CAPTURE_RESPONSE, CONF_DATA, CONF_DATA_TEMPLATE, + CONF_DELAY, + CONF_ENABLE_IPV6, CONF_ENCRYPTION, CONF_EVENT, CONF_ID, @@ -47,10 +51,14 @@ from esphome.const import ( CONF_VARIABLES, ) from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority -from esphome.cpp_generator import MockObj, TemplateArgsType -from esphome.helpers import fnv1_hash +from esphome.cpp_generator import Expression, MockObj, TemplateArgsType +import esphome.final_validate as fv +from esphome.helpers import cpp_string_escape, fnv1_hash +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigFragmentType, ConfigType +from . import wizard + # Compat alias: downstream consumers (e.g. device-builder) referenced the # schema by its old private name before it moved to the noise component _encryption_schema = encryption_schema @@ -81,10 +89,11 @@ 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 ) +HomeAssistantField = api_ns.struct("HomeAssistantField") ActionResponse = api_ns.class_("ActionResponse") HomeAssistantActionResponseTrigger = api_ns.class_( "HomeAssistantActionResponseTrigger", automation.Trigger @@ -134,6 +143,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" +CONF_OUTGOING_CONNECTION = "outgoing_connection" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" # Schema defaults that also match the C++ initializers in api_server.h; codegen @@ -141,6 +151,7 @@ CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" DEFAULT_PORT = 6053 DEFAULT_REBOOT_TIMEOUT = "15min" DEFAULT_BATCH_DELAY = "100ms" +DEFAULT_LISTEN_BACKLOG = 4 def _register_provisioning_source(config: ConfigType) -> ConfigType: @@ -291,9 +302,72 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: # (not max_connections, which is the upper limit rarely reached) socket.consume_sockets(3, "api")(config) socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config) + if CONF_OUTGOING_CONNECTION in config: + socket.consume_sockets(1, "api_outgoing_connection")(config) return config +def _validate_outgoing_connection(config: ConfigType) -> ConfigType: + if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None: + return config + if CONF_ENCRYPTION not in config: + raise cv.Invalid( + "outgoing_connection requires 'encryption' so the peer is verified by key", + path=[CONF_OUTGOING_CONNECTION], + ) + # A device with no client reboots once reboot_timeout passes, so a delay that + # reaches it would reboot the device before it ever dials + reboot_timeout = config[CONF_REBOOT_TIMEOUT] + delay = outgoing[CONF_DELAY] + if reboot_timeout.total_milliseconds and delay >= reboot_timeout: + raise cv.Invalid( + f"delay must be shorter than reboot_timeout ({reboot_timeout}), " + "otherwise the device reboots before it dials", + path=[CONF_OUTGOING_CONNECTION, CONF_DELAY], + ) + return config + + +def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address: + """Only accept an address the device itself can parse. + + Python accepts a scope id, which neither `inet_pton` nor lwIP's `inet6_aton` + takes, and a v4-mapped address is dialed as plain IPv4, needing no IPv6 build. + """ + address = cv.ipaddress(value) + if isinstance(address, IPv6Address): + if address.scope_id is not None: + raise cv.Invalid( + f"{value} carries a scope id, which the device cannot parse; " + "give the address without the '%' part" + ) + if (mapped := address.ipv4_mapped) is not None: + return mapped + return address + + +_OUTGOING_CONNECTION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_HOST): _validate_outgoing_host, + cv.Optional(CONF_PORT, default=6054): cv.port, + # Bounded against reboot_timeout in _validate_outgoing_connection + cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds, + } +) + + +@schema_extractor("schema") +def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType: + # A bare `outgoing_connection:` block is valid; without a host the device + # dials the remembered last dial-back client + if config is SCHEMA_EXTRACT: + # Let the language-schema dumper walk host, port and delay + return _OUTGOING_CONNECTION_SCHEMA + if config is None: + config = {} + return _OUTGOING_CONNECTION_SCHEMA(config) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -318,6 +392,8 @@ CONFIG_SCHEMA = cv.All( ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(wizard.CONF_WIZARD): wizard.WIZARD_SCHEMA, + cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema, cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), @@ -373,6 +449,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), + _validate_outgoing_connection, _consume_api_sockets, _register_provisioning_source, ) @@ -429,7 +506,34 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: return config -FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings +def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: + """An IPv6 host can never be parsed, so never dialed, without IPv6.""" + if ( + (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None + or (host := outgoing.get(CONF_HOST)) is None + or host.version != 6 + ): + return config + network_conf = fv.full_config.get().get("network") or {} + if not network_conf.get(CONF_ENABLE_IPV6): + raise cv.Invalid( + "outgoing_connection host is an IPv6 address but IPv6 is not " + "enabled; set 'network: enable_ipv6: true'", + path=[CONF_OUTGOING_CONNECTION, CONF_HOST], + ) + return config + + +def _validate_wizard(config: ConfigType) -> ConfigType: + wizard.final_validate(config) + return config + + +FINAL_VALIDATE_SCHEMA = cv.All( + _validate_esp8266_action_strings, + _validate_outgoing_host_ipv6, + _validate_wizard, +) def _add_action_strings( @@ -462,8 +566,7 @@ 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() @@ -477,8 +580,10 @@ async def to_code(config: ConfigType) -> None: 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])) + if ( + listen_backlog := config.get(CONF_LISTEN_BACKLOG) + ) is not None and listen_backlog != DEFAULT_LISTEN_BACKLOG: + cg.add(var.set_listen_backlog(listen_backlog)) cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) @@ -583,6 +688,9 @@ async def to_code(config: ConfigType) -> None: # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) + if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None: + await wizard.to_code(wizard_config) + if CONF_ON_CLIENT_CONNECTED in config: cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER") await automation.build_automation( @@ -601,7 +709,7 @@ async def to_code(config: ConfigType) -> None: if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None: if key := encryption_config.get(CONF_KEY): - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) + cg.add(var.set_noise_psk(new_psk_progmem(key))) cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired @@ -614,9 +722,17 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") + enable_spare_ephemeral() else: cg.add_define("USE_API_PLAINTEXT") + if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None: + cg.add_define("USE_API_OUTGOING_CONNECTION") + if (host := outgoing.get(CONF_HOST)) is not None: + cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host)) + cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT]) + cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY]) + cg.add_define("USE_API") cg.add_global(api_ns.using) @@ -657,6 +773,11 @@ VARIABLES_SCHEMA = cv.Schema( {cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))} ) +# The action stores each map's entry count in a uint8_t +_FIELD_MAP_MAX = 255 +DATA_FIELDS_SCHEMA = cv.All(KEY_VALUE_SCHEMA, cv.Length(max=_FIELD_MAP_MAX)) +VARIABLES_FIELDS_SCHEMA = cv.All(VARIABLES_SCHEMA, cv.Length(max=_FIELD_MAP_MAX)) + def _validate_response_config(config: ConfigType) -> ConfigType: # Validate dependencies: @@ -691,9 +812,9 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.templatable( cv.string ), - cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, + cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA, cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string), cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean, cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), @@ -706,6 +827,62 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) +def _field_string(value: str) -> Expression: + # ESP8266 can only keep a string in flash as its own PROGMEM array + literal = cg.RawExpression(cpp_string_escape(value)) + if CORE.is_esp8266: + return cg.shared_progmem_array("ha_field_str", cg.char, literal) + return literal + + +async def _new_service_call_action( + server_id: ID, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + is_event: bool, + service: Any, + data: dict[str, Any], + data_template: dict[str, Any], + variables: dict[str, Any], +) -> MockObj: + """Create the action with its name and fields in one shared flash table.""" + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + serv = await cg.get_variable(server_id) + field_type = HomeAssistantField.template(template_arg) + groups = [data, data_template, variables] + # A lambda may keep static state, so a table with lambdas is never shared + has_lambda = cg.is_template(service) or any( + cg.is_template(value) for group in groups for value in group.values() + ) + + async def field(key: str | None, value: Any, output_type: Any = None) -> Expression: + key_exp = cg.nullptr if key is None else _field_string(key) + if cg.is_template(value): + # output_type=None lets lambdas return numbers or char pointers; C++ converts them + lam = await cg.process_lambda(value, args, return_type=output_type) + return cg.RawExpression(f"{field_type}::from_lambda({key_exp}, {lam})") + return cg.ArrayInitializer(key_exp, _field_string(value), cg.nullptr) + + entries = [await field(None, service, cg.std_string)] + for group in groups: + for key, value in group.items(): + entries.append(await field(key, value)) + table = cg.shared_progmem_array( + "ha_action_fields", + field_type, + cg.ArrayInitializer(*entries, multiline=True), + share=not has_lambda, + ) + return cg.new_Pvariable( + action_id, template_arg, serv, is_event, table, *(len(g) for g in groups) + ) + + +def _service_call_fields(config: ConfigType) -> tuple[dict[str, Any], ...]: + return config[CONF_DATA], config[CONF_DATA_TEMPLATE], config[CONF_VARIABLES] + + # synchronous=False: when on_success/on_error is configured, play() stores the # trigger args until the HomeassistantActionResponse arrives, so non-owning args # (StringRef into the API receive buffer) must not be used. @@ -727,36 +904,15 @@ async def homeassistant_service_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, False) - templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string) - cg.add(var.set_service(templ)) - - # Initialize FixedVectors with exact sizes from config - cg.add(var.init_data(len(config[CONF_DATA]))) - for key, value in config[CONF_DATA].items(): - # output_type=None because lambdas can return non-string types (int, - # float, char*) that TemplatableStringValue converts via to_string. - # Static strings are manually wrapped for PROGMEM on ESP8266. - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE]))) - for key, value in config[CONF_DATA_TEMPLATE].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_variables(len(config[CONF_VARIABLES]))) - for key, value in config[CONF_VARIABLES].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) + var = await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + False, + config[CONF_ACTION], + *_service_call_fields(config), + ) if on_error := config.get(CONF_ON_ERROR): cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES") @@ -808,9 +964,9 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(APIServer), cv.Required(CONF_EVENT): validate_homeassistant_event, - cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, + cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA, } ) @@ -829,38 +985,15 @@ async def homeassistant_event_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, True) - templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string) - cg.add(var.set_service(templ)) - - # Initialize FixedVectors with exact sizes from config - cg.add(var.init_data(len(config[CONF_DATA]))) - for key, value in config[CONF_DATA].items(): - # output_type=None because lambdas can return non-string types (int, - # float, char*) that TemplatableStringValue converts via to_string. - # Static strings are manually wrapped for PROGMEM on ESP8266. - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE]))) - for key, value in config[CONF_DATA_TEMPLATE].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_variables(len(config[CONF_VARIABLES]))) - for key, value in config[CONF_VARIABLES].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) - - return var + return await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + True, + config[CONF_EVENT], + *_service_call_fields(config), + ) HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( @@ -884,15 +1017,17 @@ async def homeassistant_tag_scanned_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, True) - cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned"))) - # Initialize FixedVector with exact size (1 data field) - cg.add(var.init_data(1)) - templ = await cg.templatable(config[CONF_TAG], args, cg.std_string) - cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ)) - return var + return await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + True, + "esphome.tag_scanned", + {"tag_id": config[CONF_TAG]}, + {}, + {}, + ) CONF_SUCCESS = "success" @@ -1003,6 +1138,8 @@ _define_filter = filter_source_files_from_defines( "user_services.cpp": "USE_API_USER_DEFINED_ACTIONS", "api_frame_helper_noise.cpp": "USE_API_NOISE", "api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT", + "api_wizard.cpp": "USE_API_WIZARD", + "api_outgoing_connection.cpp": "USE_API_OUTGOING_CONNECTION", } ) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 0ef4c1f1820..711c5f6b0e9 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -20,6 +20,8 @@ service APIConnection { option (needs_authentication) = false; } rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} + rpc device_wizard (DeviceWizardRequest) returns (DeviceWizardResponse) {} + rpc wizard_input_set (WizardInputSetRequest) returns (void) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -76,6 +78,7 @@ service APIConnection { rpc serial_proxy_write(SerialProxyWriteRequest) returns (void) {} rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {} rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {} + rpc subscribe_serial_proxy_identity(SubscribeSerialProxyIdentityRequest) returns (void) {} rpc serial_proxy_request(SerialProxyRequest) returns (void) {} rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {} @@ -115,6 +118,11 @@ message HelloRequest { string client_info = 1; uint32 api_version_major = 2; uint32 api_version_minor = 3; + + // Set by clients that can accept connections the device opens to them + // (see api: outgoing_connection:). The device remembers this client's + // address as the target to dial when no such client is connected. + bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"]; } // Confirmation of successful connection request. @@ -230,6 +238,7 @@ enum SerialProxyPortType { SERIAL_PROXY_PORT_TYPE_TTL = 0; SERIAL_PROXY_PORT_TYPE_RS232 = 1; SERIAL_PROXY_PORT_TYPE_RS485 = 2; + SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3; // since API 1.18 } message SerialProxyInfo { @@ -334,6 +343,10 @@ message DeviceInfoResponse { // all-zeros PSK, so the api encryption key can be provisioned without being // sent in plaintext (protects against passive sniffing, not active MITM) bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; + + // Device is built with the api outgoing_connection option and can open + // the TCP connection to a dial-back target itself + bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"]; } // ==================== DEVICE CAPABILITIES ==================== @@ -388,6 +401,11 @@ message StoreYamlCapabilities { bool supported = 1; } +message WizardCapabilities { + // True when the device has a wizard configured, so DeviceWizardRequest will be answered + bool configured = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -397,7 +415,91 @@ message DeviceCapabilitiesResponse { ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"]; repeated SerialProxyInfo serial_proxies = 4 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; - StoreYamlCapabilities store_yaml = 5 [(field_ifdef) = "USE_STORE_YAML"]; + WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; + StoreYamlCapabilities store_yaml = 6 [(field_ifdef) = "USE_STORE_YAML"]; +} + +// ==================== DEVICE WIZARD ==================== + +// Asks the device for the onboarding wizard that Home Assistant presents when the +// device is added. +// +// The wizard is read once, when the device is added, whereas capabilities are read +// on every connect, so it is a separate message rather than part of +// DeviceCapabilitiesResponse. It is only served on an authenticated connection. +// +// A device without a wizard does not have this message at all and never answers it, +// as a device ignores messages it does not know. Clients therefore check +// DeviceCapabilitiesResponse.wizard.configured before sending it. +message DeviceWizardRequest { + option (id) = 156; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_WIZARD"; + // Empty +} + +// The wizard, as UTF-8 JSON compressed into a single zstd frame. The device builds it +// when it is compiled and sends it unchanged, so its size varies with the wizard and +// its format can grow without new fields here. Clients decompress it, then read: +// +// {"version": 1, +// "pages": [{"title": "...", "description": "...", +// "entities": [{"key": 123, "device_id": 456, "description": "..."}], +// "inputs": [{"key": 789, "description": "...", +// "entity_filters": [{"integration": "...", "domain": ["..."], +// "device_class": ["..."], "supported_features": ["..."]}]}]}]} +// +// Values that are empty or unset are left out, and so are empty lists. Strings may be +// Home Assistant translation placeholders such as "[%key:component::domain::name%]", +// passed through verbatim. The pages are shown in the order listed. A client that does +// not know the version must not use the wizard. +// +// An entity is an entity of this device that the page shows to the user (for example to +// turn it on or off). Its key is the key ListEntitiesResponse sends for it. Its +// device_id is the device_id ListEntitiesResponse sends, and is left out when that is 0. +// +// An input is filled in by the user with the id of a Home Assistant entity. It either +// stands for one entity of the device's homeassistant platform (sensor, binary_sensor, +// text_sensor, number, switch, text, select or button) that sets no entity_id of its own, +// as Home Assistant supplies it, or it is a standalone input that only stores the entity +// id for the device's own automations. Its key is the FNV-1 hash +// of the input's ESPHome id, not an entity key. The client sends the chosen entity id to +// the device with a WizardInputSetRequest. entity_filters says which Home Assistant +// entities the user may choose, and mirrors Home Assistant's EntityFilterSelectorConfig: +// an entity matches a filter when it satisfies every field that is set, and an entity +// that matches any one of the filters is accepted. +// +// The device keeps the entity id of an input in RAM only and does not store it across +// restarts. The client therefore stores the choice itself and, after every connection +// and before it sends SubscribeHomeAssistantStatesRequest, sends a WizardInputSetRequest +// for each input. It sends one again whenever the user changes the choice. +message DeviceWizardResponse { + option (id) = 157; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_API_WIZARD"; + // Not logged: the data is in flash, which ESP8266 cannot read for a dump + option (log) = false; + + // The data is in flash, and the device sends it from there + bytes data = 1 [(pointer_to_buffer) = true]; +} + +// Sets the entity id of a wizard input (see WizardInputField.key). Clients send +// this only when DeviceCapabilitiesResponse.wizard.configured is set. There is no +// reply. The device ignores a request whose key matches no input or whose entity_id +// is empty, longer than 255 bytes or has no '.'. Otherwise it keeps the id in RAM, +// not across restarts, and when the input belongs to a homeassistant platform entity +// it subscribes to that entity's state. Clients send this for every input right +// after connecting, before SubscribeHomeAssistantStatesRequest. A request that +// arrives later still works: the device then sends the affected state subscriptions +// again. +message WizardInputSetRequest { + option (id) = 158; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_API_WIZARD_INPUTS"; + + fixed32 key = 1; + string entity_id = 2 [(max_data_length) = 255]; } message ListEntitiesRequest { @@ -812,6 +914,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; @@ -1253,6 +1358,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; @@ -1339,6 +1447,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 @@ -1439,7 +1550,7 @@ message ListEntitiesSelectResponse { reserved 4; // Deprecated: was string unique_id string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; - repeated string options = 6 [(container_pointer_no_template) = "FixedVector"]; + repeated string options = 6 [(container_pointer_no_template) = "std::span"]; bool disabled_by_default = 7; EntityCategory entity_category = 8; uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"]; @@ -2683,7 +2794,7 @@ message ListEntitiesInfraredResponse { message InfraredRFTransmitRawTimingsRequest { option (id) = 136; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; + option (ifdef) = "USE_IR_RF"; uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance @@ -2697,7 +2808,7 @@ message InfraredRFTransmitRawTimingsRequest { message InfraredRFReceiveEvent { option (id) = 137; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY"; + option (ifdef) = "USE_IR_RF"; option (no_delay) = true; option (speed_optimized) = true; @@ -2706,6 +2817,28 @@ message InfraredRFReceiveEvent { repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods } +// Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the +// transmitter reports that the transmission (all repeats) has finished, or immediately with +// success=false if it was refused before reaching the transmitter (unknown key, no transmitter, +// no or invalid timings) or the transmitter could not send it (not set up, hardware error). +// success=false also answers a request superseded by a newer request on the same entity, from +// any client, and a transmit that reported nothing within 30 s past its expected air time, so a +// client should not retry on it blindly. The device serializes transmits per transmitter and +// several entities may share one, so a client should keep at most one transmit outstanding per +// device, not per entity. A reply for an unknown key or a refused request is sent once and can be lost +// when the device's send buffer is full, so a client should also stop waiting on its own after the +// expected air time plus a margin. Lets clients pace requests instead of estimating durations (since API 1.18) +message InfraredRFTransmitCompleteResponse { + option (id) = 153; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_IR_RF"; + option (no_delay) = true; + + uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"]; + fixed32 key = 2 [(force) = true]; // Key of the transmitter entity from the request + bool success = 3; // false if the request was refused, not sent, superseded, or never reported +} + // ==================== RADIO FREQUENCY ==================== // Lists available radio frequency entity instances @@ -2876,6 +3009,56 @@ message SerialProxySetModeRequest { SerialProxyMode mode = 2; } +// Subscribe to the identity of every serial proxy port. The device answers with one +// SerialProxyIdentity per port, then sends another whenever a port's identity changes, +// for the life of the connection (since API 1.18). +message SubscribeSerialProxyIdentityRequest { + option (id) = 154; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; +} + +// Where a port's identity comes from +enum SerialProxyIdentitySource { + SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0; // The port carries no identity + SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1; // Reserved for identities stated in the device configuration; not sent yet + SERIAL_PROXY_IDENTITY_SOURCE_USB = 2; // Read from the descriptors of the USB device behind the port; + // changes when a device is attached or removed +} + +enum SerialProxyIdentityFlag { + SERIAL_PROXY_IDENTITY_FLAG_NONE = 0; + SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1; // The backend believes the device is present and usable on this port + SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2; // The USB host stack refused the descriptor query; the strings and + // IDs below are empty +} + +// The descriptor fields of a USB device as seen by the host stack +message UsbDeviceDescriptor { + uint32 vendor_id = 1; + uint32 product_id = 2; + uint32 bcd_device = 3; + uint32 interface_number = 4; // bInterfaceNumber the host driver binds to +} + +// The identity of the device behind a port. Identifies one physical device among others of +// the same kind, so a client matches on manufacturer and product regardless of source. The +// strings are empty for source NONE, and for source USB while nothing is connected +// (since API 1.18). +message SerialProxyIdentity { + option (id) = 155; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyIdentitySource source = 2; + uint32 flags = 3; // Bitmask of SerialProxyIdentityFlag + string manufacturer = 4; + string product = 5; + string serial_number = 6; + UsbDeviceDescriptor usb = 7; // Only sent for source USB +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; @@ -2907,14 +3090,14 @@ message BluetoothSetConnectionParamsResponse { // connection is ignored; the in-flight transfer continues undisturbed. To // restart a transfer, reconnect. message GetYamlRequest { - option (id) = 154; + option (id) = 159; option (source) = SOURCE_CLIENT; option (ifdef) = "USE_STORE_YAML"; option (no_delay) = true; } message GetYamlResponse { - option (id) = 155; + option (id) = 160; option (source) = SOURCE_SERVER; option (ifdef) = "USE_STORE_YAML"; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 767592a9a72..954d480f595 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -201,6 +201,17 @@ APIConnection::~APIConnection() { proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE); } } +#endif + // entities holding a transmit reply for this client must not answer into a freed connection +#ifdef USE_INFRARED + for (auto *infrared : App.get_infrareds()) { + infrared->on_api_connection_closed(this); + } +#endif +#ifdef USE_RADIO_FREQUENCY + for (auto *radio_frequency : App.get_radio_frequencies()) { + radio_frequency->on_api_connection_closed(this); + } #endif } @@ -610,7 +621,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); @@ -719,6 +730,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); } @@ -764,6 +776,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 | @@ -998,7 +1011,9 @@ uint16_t APIConnection::try_send_select_state(EntityBase *entity, APIConnection uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) { auto *select = static_cast(entity); ListEntitiesSelectResponse msg; - msg.options = &select->traits.get_options(); + const auto &opts = select->traits.get_options(); + const std::span options(opts.data(), opts.size()); + msg.options = &options; return fill_and_encode_entity_info(select, msg, conn, remaining_size); } void APIConnection::on_select_command_request(const SelectCommandRequest &msg) { @@ -1540,6 +1555,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(); @@ -1605,8 +1621,10 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) { + // Clients on API 1.18+ are told when the frame has left the transmitter; the entity owns that reply + const bool want_reply = this->client_supports_api_version(1, 18); // Dispatch by key: infrared entities are checked first, then radio frequency entities. // The key is unique across all entity instances on a device, so at most one lookup will succeed. #ifdef USE_INFRARED @@ -1616,6 +1634,7 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF call.set_carrier_frequency(msg.carrier_frequency); call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); call.set_repeat_count(msg.repeat_count); + call.set_api_connection(want_reply ? this : nullptr); call.perform(); return; } @@ -1628,13 +1647,38 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF call.set_modulation(static_cast(msg.modulation)); call.set_repeat_count(msg.repeat_count); call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_); + call.set_api_connection(want_reply ? this : nullptr); call.perform(); + return; } #endif + ESP_LOGW(TAG, "IR/RF transmit for unknown key %" PRIu32, msg.key); + if (want_reply) { + // nothing will ever report for an unknown key, so answer as not started right away +#ifdef USE_DEVICES + const uint32_t device_id = msg.device_id; +#else + const uint32_t device_id = 0; +#endif + if (!this->send_infrared_rf_transmit_complete(device_id, msg.key, false)) { + API_LOG_MSG_DROPPED(TAG, "IR/RF reply"); + } + } +} + +bool APIConnection::send_infrared_rf_transmit_complete([[maybe_unused]] uint32_t device_id, uint32_t key, + bool success) { + InfraredRFTransmitCompleteResponse resp{}; +#ifdef USE_DEVICES + resp.device_id = device_id; +#endif + resp.key = key; + resp.success = success; + return this->send_message(resp); } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { if (!this->send_message(msg)) { // V: fires per decoded frame with no subscription gate, so a warning @@ -1642,6 +1686,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); } } + #endif #ifdef USE_SERIAL_PROXY @@ -1733,6 +1778,22 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM } } +void APIConnection::on_subscribe_serial_proxy_identity_request() { +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + // Only USB ports change identity after this snapshot + this->flags_.serial_proxy_identity_subscription = true; +#endif + for (auto *proxy : App.get_serial_proxies()) { + proxy->send_identity(this); + } +} + +void APIConnection::send_serial_proxy_identity(const SerialProxyIdentity &msg) { + if (!this->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy identity"); + } +} + void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { auto &proxies = App.get_serial_proxies(); if (msg.instance >= proxies.size()) { @@ -1904,7 +1965,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 17; + resp.api_version_minor = 18; // 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()); @@ -1927,6 +1988,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Auto-authenticate - password auth was removed in ESPHome 2026.1.0 this->complete_authentication_(); +#ifdef USE_API_OUTGOING_CONNECTION + // With a PSK set only key-verified transports reach hello: plaintext and + // zero-PSK are rejected, and pre-activation sessions are force-closed + if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target) { + if (this->parent_->get_noise_ctx().has_psk()) { + this->flags_.outgoing_connection_target = true; + this->parent_->on_outgoing_target_client(this); + } else { + this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Dial-back target refused; no key active")); + } + } +#endif + return this->send_message(resp); } @@ -2049,6 +2123,9 @@ bool APIConnection::send_device_info_response_() { // one) so this advertisement survives the plaintext removal in 2027.2.0. resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk(); #endif +#ifdef USE_API_OUTGOING_CONNECTION + resp.api_outgoing_connection_supported = true; +#endif #endif #ifdef USE_DEVICES size_t device_index = 0; @@ -2102,6 +2179,9 @@ bool APIConnection::send_device_capabilities_response_() { info.configured_line_states = proxy->get_configured_modem_pins(); } #endif +#ifdef USE_API_WIZARD + resp.wizard.configured = true; +#endif #ifdef USE_STORE_YAML // Compile-time knowledge: codegen always embeds a non-empty blob when the // component is compiled in. Deriving this from the runtime pointer could @@ -2321,7 +2401,13 @@ void APIConnection::on_noise_encryption_set_key_request(const NoiseEncryptionSet } #endif #ifdef USE_API_HOMEASSISTANT_STATES -void APIConnection::on_subscribe_home_assistant_states_request() { state_subs_at_ = 0; } +void APIConnection::on_subscribe_home_assistant_states_request() { +#ifdef USE_API_WIZARD_LINKED_INPUTS + // Remember it, as a client that subscribed also gets the subscriptions again when a wizard input is set + this->flags_.home_assistant_states = true; +#endif + state_subs_at_ = 0; +} #endif bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { delay(0); @@ -2370,8 +2456,12 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, #endif // 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, shared_buf.data() + write_start 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) @@ -2764,6 +2854,13 @@ void APIConnection::process_state_subscriptions_() { } const auto &it = subs[this->state_subs_at_]; +#ifdef USE_API_WIZARD_LINKED_INPUTS + // An entity id that is not set yet (a wizard input) has nothing to subscribe to; it is sent once it is set + if (it.entity_id[0] == '\0') { + this->state_subs_at_++; + return; + } +#endif SubscribeHomeAssistantStateResponse resp; resp.entity_id = StringRef(it.entity_id); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 150fe832e68..7931bf7e567 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -237,9 +237,12 @@ class APIConnection final : public APIServerConnectionBase { void on_water_heater_command_request(const WaterHeaterCommandRequest &msg); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg); void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); + // Reply to an InfraredRFTransmitRawTimingsRequest (API 1.18+); false when the TCP buffer is + // full, the entity that owns the reply retries it then + [[nodiscard]] bool send_infrared_rf_transmit_complete(uint32_t device_id, uint32_t key, bool success); #endif #ifdef USE_SERIAL_PROXY @@ -247,6 +250,9 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg); void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); + void on_subscribe_serial_proxy_identity_request(); + /// Send a port identity to this client + void send_serial_proxy_identity(const SerialProxyIdentity &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); @@ -277,6 +283,12 @@ class APIConnection final : public APIServerConnectionBase { void on_ping_request(); void on_device_info_request(); void on_device_capabilities_request(); +#ifdef USE_API_WIZARD + void on_device_wizard_request(); +#endif +#ifdef USE_API_WIZARD_INPUTS + void on_wizard_input_set_request(const WizardInputSetRequest &msg); +#endif void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -306,6 +318,10 @@ class APIConnection final : public APIServerConnectionBase { #endif #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(); +#ifdef USE_API_WIZARD_LINKED_INPUTS + /// Tell this client about the subscriptions whose entity id is stored in the given buffer, as the buffer changed + void resend_state_subscriptions(const char *entity_id); +#endif #endif #ifdef USE_API_USER_DEFINED_ACTIONS void on_execute_service_request(const ExecuteServiceRequest &msg); @@ -321,9 +337,15 @@ class APIConnection final : public APIServerConnectionBase { void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg); #endif + // How long a new connection holds off the spare ephemeral refill + static constexpr uint32_t CONNECT_GRACE_MS = 1000; bool is_authenticated() { return static_cast(this->flags_.connection_state) == ConnectionState::AUTHENTICATED; } + // An older unauthenticated connection is a stale half open client and does not count + bool is_still_connecting(uint32_t now) { + return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS; + } bool is_connection_setup() { return static_cast(this->flags_.connection_state) == ConnectionState::CONNECTED || this->is_authenticated(); @@ -343,18 +365,14 @@ class APIConnection final : public APIServerConnectionBase { void on_no_setup_connection(); // Function pointer type for type-erased message encoding - using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM); + using MessageEncodeFn = ProtoEncodeFn; // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); /// 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. @@ -380,6 +398,23 @@ class APIConnection final : public APIServerConnectionBase { return this->helper_->get_peername_to(buf); } +#ifdef USE_API_OUTGOING_CONNECTION + /// Get the peer address itself, for remembering a dial-back target + int getpeername(struct sockaddr *addr, socklen_t *addrlen) const { return this->helper_->getpeername(addr, addrlen); } + /// Outgoing connection: send our server hello immediately so the peer can + /// pick the matching key. Outgoing connections are only dialed when a PSK + /// is set, so the helper is always the noise helper. Call after start(). + void mark_outgoing() { + if (this->flags_.remove) { + return; // start() failed; the connection is already being torn down + } + APIError err = static_cast(this->helper_.get())->send_server_hello_first(); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Server hello failed"), err); + } + } +#endif + protected: bool try_to_clear_buffer_slow_(bool log_out_of_space); @@ -392,6 +427,9 @@ class APIConnection final : public APIServerConnectionBase { bool send_ping_response_(); bool send_device_info_response_(); bool send_device_capabilities_response_(); +#ifdef USE_API_WIZARD + bool send_device_wizard_response_(); +#endif #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif @@ -422,16 +460,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); @@ -450,11 +478,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 @@ -466,7 +490,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 @@ -478,7 +502,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 @@ -492,8 +516,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 @@ -762,12 +786,21 @@ class APIConnection final : public APIServerConnectionBase { uint8_t batch_first_message : 1; // For batch buffer allocation uint8_t should_try_send_immediately : 1; // True after initial states are sent uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check +#ifdef USE_API_WIZARD_LINKED_INPUTS + uint8_t home_assistant_states : 1; // Client subscribed to Home Assistant states +#endif +#ifdef USE_API_OUTGOING_CONNECTION + uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello +#endif #ifdef HAS_PROTO_MESSAGE_DUMP uint8_t log_only_mode : 1; #endif - } flags_{}; // 2 bytes total +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + uint8_t serial_proxy_identity_subscription : 1; +#endif + } flags_{}; // 2 bytes; 3 with HAS_PROTO_MESSAGE_DUMP + USE_API_OUTGOING_CONNECTION + USE_SERIAL_PROXY_USB_IDENTITY - // 2-byte type immediately after flags_ (no padding between them) + // 2-byte type immediately after flags_ (one padding byte when flags_ is 3 bytes) uint16_t batch_message_type_{0}; // Current message type during batch encoding // 1-byte types to fill remaining space before next 4-byte boundary // Client API versions are clamped to 255 on receive (see send_hello_response_) diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h index 08520249bf6..9b05da76e5d 100644 --- a/esphome/components/api/api_connection_buffer.h +++ b/esphome/components/api/api_connection_buffer.h @@ -45,8 +45,14 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c conn->fatal_out_of_memory_(); 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, shared_buf.data() + shared_buf.size() - calculated_size 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.h b/esphome/components/api/api_frame_helper.h index a68a0ad0d87..96c302745c8 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -285,7 +285,8 @@ class APIFrameHelper { DATA = 5, CLOSED = 6, FAILED = 7, - EXPLICIT_REJECT = 8, // Noise only + EXPLICIT_REJECT = 8, // Noise only + CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn) }; // Fast inline state check for read_packet/write_protobuf_messages hot path. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 400cd1d9b86..21ef085c53f 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) @@ -72,15 +65,18 @@ APIError APINoiseFrameHelper::init() { state_ = State::FAILED; return APIError::OUT_OF_MEMORY; } -#ifdef USE_ESP8266 - memcpy_P(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); -#else - std::memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); -#endif + progmem_memcpy(dst, PROLOGUE_INIT, PROLOGUE_INIT_LEN); state_ = State::CLIENT_HELLO; return APIError::OK; } +#ifdef USE_API_OUTGOING_CONNECTION +APIError APINoiseFrameHelper::send_server_hello_first() { + // The peer needs our name and MAC to pick the key before its first message + this->state_ = State::CLIENT_HELLO_OUTGOING; + return this->send_server_hello_frame_(); +} +#endif #ifdef USE_API_PLAINTEXT APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) { APIError err = this->init(); @@ -253,6 +249,9 @@ APIError APINoiseFrameHelper::state_action_() { HELPER_LOG("Bad state for method: %d", (int) this->state_); return APIError::BAD_STATE; case State::CLIENT_HELLO: +#ifdef USE_API_OUTGOING_CONNECTION + case State::CLIENT_HELLO_OUTGOING: +#endif return this->state_action_client_hello_(); case State::SERVER_HELLO: return this->state_action_server_hello_(); @@ -285,11 +284,16 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { std::memcpy(dst + 2, this->rx_buf_.data(), rx_size); } +#ifdef USE_API_OUTGOING_CONNECTION + if (this->state_ == State::CLIENT_HELLO_OUTGOING) { + // Server hello already went out at handoff + return this->start_handshake_(); + } +#endif state_ = State::SERVER_HELLO; return APIError::OK; } -APIError APINoiseFrameHelper::state_action_server_hello_() { - // send server hello +APIError APINoiseFrameHelper::send_server_hello_frame_() { const auto &name = App.get_name(); char mac[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac); @@ -313,15 +317,18 @@ APIError APINoiseFrameHelper::state_action_server_hello_() { // node mac, terminated by null byte std::memcpy(msg + mac_offset, mac, MAC_ADDRESS_BUFFER_SIZE); - APIError aerr = write_frame_(msg, total_size); + return write_frame_(msg, total_size); +} +APIError APINoiseFrameHelper::state_action_server_hello_() { + APIError aerr = this->send_server_hello_frame_(); if (aerr != APIError::OK) return aerr; - - // start handshake - aerr = init_handshake_(); + return this->start_handshake_(); +} +APIError APINoiseFrameHelper::start_handshake_() { + APIError aerr = init_handshake_(); if (aerr != APIError::OK) return aerr; - state_ = State::HANDSHAKE; return APIError::OK; } diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 366751738e6..a807d6e5da3 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -28,6 +28,12 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Seeds the already-read header bytes and pumps the handshake state machine // until it would block. APIError init_from_handoff(const uint8_t *header, uint8_t header_len); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + // Send the server hello immediately so the peer can pick the key before + // its PSK-mixed message. Call after init(); the mode is tracked in state_ + // so the helper does not grow. + APIError send_server_hello_first(); #endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; @@ -39,6 +45,8 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError state_action_(); APIError state_action_client_hello_(); APIError state_action_server_hello_(); + APIError send_server_hello_frame_(); + APIError start_handshake_(); APIError state_action_handshake_(); APIError state_action_handshake_read_(); APIError state_action_handshake_write_(); diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp new file mode 100644 index 00000000000..c86107cef0b --- /dev/null +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -0,0 +1,318 @@ +#include "api_outgoing_connection.h" +#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION) + +#include "api_connection.h" +#include "api_server.h" +#include "esphome/components/network/util.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include +#include + +namespace esphome::api { + +static const char *const TAG = "api.outgoing"; + +#ifndef API_OUTGOING_CONNECTION_HOST +static constexpr uint32_t OUTGOING_TARGET_PREF_HASH = 629847102UL; +#endif + +#ifndef API_OUTGOING_CONNECTION_HOST +// Read the connection's peer address into target; false when unavailable or +// of a family this build cannot dial +static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) { + // Zeroed because the raw lwIP getpeername() leaves sin6_scope_id untouched + struct sockaddr_storage peer = {}; + socklen_t peer_len = sizeof(peer); + if (conn->getpeername((struct sockaddr *) &peer, &peer_len) != 0) { + return false; + } + const sa_family_t family = ((struct sockaddr *) &peer)->sa_family; +#if USE_NETWORK_IPV6 + if (family == AF_INET6) { + const auto *addr6 = reinterpret_cast(&peer); + const auto *bytes = reinterpret_cast(&addr6->sin6_addr); + uint32_t prefix[3]; + memcpy(prefix, bytes, sizeof(prefix)); + // A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d + if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == htonl(0xFFFFUL)) { + target.family = AF_INET; + memcpy(target.addr, bytes + sizeof(prefix), sizeof(struct in_addr)); + return true; + } + // A link-local target is only reachable through the interface it came in + // on. Device platforms number interfaces from one; a host build can hand + // out an index too large to store, and a truncated one dials the wrong + // interface, so that target is not remembered at all. + if (addr6->sin6_scope_id > UINT8_MAX) { + return false; + } + target.family = AF_INET6; + memcpy(target.addr, bytes, sizeof(target.addr)); + target.scope_id = static_cast(addr6->sin6_scope_id); + return true; + } +#endif + if (family != AF_INET) { + return false; + } + const auto *addr4 = reinterpret_cast(&peer); + target.family = AF_INET; + memcpy(target.addr, &addr4->sin_addr, sizeof(addr4->sin_addr)); + return true; +} +#endif + +socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *addr) const { +#ifdef API_OUTGOING_CONNECTION_HOST + // Validation only lets through a literal both inet_pton and inet6_aton + // accept, so this cannot fail + return socket::set_sockaddr((struct sockaddr *) addr, sizeof(*addr), API_OUTGOING_CONNECTION_HOST, + API_OUTGOING_CONNECTION_PORT); +#else +#if USE_NETWORK_IPV6 + if (this->saved_.family == AF_INET6) { + auto *addr6 = reinterpret_cast(addr); + memset(addr6, 0, sizeof(*addr6)); + addr6->sin6_family = AF_INET6; + addr6->sin6_port = htons(API_OUTGOING_CONNECTION_PORT); + memcpy(&addr6->sin6_addr, this->saved_.addr, sizeof(this->saved_.addr)); + addr6->sin6_scope_id = this->saved_.scope_id; + return sizeof(*addr6); + } +#endif + if (this->saved_.family != AF_INET) { + return 0; + } + auto *addr4 = reinterpret_cast(addr); + memset(addr4, 0, sizeof(*addr4)); + addr4->sin_family = AF_INET; + addr4->sin_port = htons(API_OUTGOING_CONNECTION_PORT); + memcpy(&addr4->sin_addr, this->saved_.addr, sizeof(addr4->sin_addr)); + return sizeof(*addr4); +#endif +} + +#ifndef API_OUTGOING_CONNECTION_HOST +void OutgoingConnectionManager::format_target_(std::span buf) const { + struct sockaddr_storage addr; + socklen_t addr_len = this->target_sockaddr_(&addr); + if (addr_len == 0) { + buf[0] = '\0'; + return; + } + // Clears buf itself if it cannot format the address + socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, buf); +} +#endif + +void OutgoingConnectionManager::setup() { +#ifndef API_OUTGOING_CONNECTION_HOST + this->target_pref_ = global_preferences->make_preference(OUTGOING_TARGET_PREF_HASH, true); + struct sockaddr_storage addr; + // dump_config() prints whichever target this leaves in place + if (this->target_pref_.load(&this->saved_) && this->target_sockaddr_(&addr) != 0) { + this->host_persisted_ = true; + } else { + // Never saved, failed its size or CRC check, or holds an unknown family + this->saved_ = {}; + } +#endif +} + +void OutgoingConnectionManager::loop(APIServer *server) { + if (server->has_outgoing_target_client_()) { + return; // on_target_client() already reset the dial state + } + if (this->dialed_conn_ != nullptr) { + // A live dialed session (flagged or not, e.g. a host: peer) is the + // target; a silent one dies on the handshake timeout + return; + } + const uint32_t now = App.get_loop_component_start_time(); + switch (this->state_) { + case DialState::DIAL_STATE_IDLE: + // Target went away; give it the configured delay to reconnect first + this->schedule_wait_(now, IDLE_WAIT_MS); + break; + case DialState::DIAL_STATE_WAITING: + if (now - this->state_ts_ >= this->wait_) { + this->try_dial_(server, now); + } + break; + case DialState::DIAL_STATE_CONNECTING: + this->poll_connect_(server, now); + break; + } +} + +void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { + if (!network::is_connected()) { + // Flips within seconds of boot; recheck fast so a deep sleep wake + // window is not spent waiting + this->schedule_wait_(now, NETWORK_RETRY_MS); + return; + } + struct sockaddr_storage addr; + socklen_t addr_len = this->target_sockaddr_(&addr); + const bool at_limit = server->at_client_limit_(); + // No target is the steady state until a dial-back client has ever connected + if (addr_len == 0 || at_limit || !server->noise_ctx_.has_psk()) { + // Repeats for as long as the reason holds, so keep it out of debug logs + ESP_LOGV(TAG, "Not dialing: %s", + addr_len == 0 ? LOG_STR_LITERAL("no target") + : (at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key"))); + // Not a dial failure; retry without escalating the backoff + this->schedule_wait_(now, PRECONDITION_RETRY_MS); + return; + } + this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP); + if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) { + ESP_LOGW(TAG, "Socket %s failed: errno %d", + this->dial_socket_ ? LOG_STR_LITERAL("setblocking") : LOG_STR_LITERAL("create"), errno); + this->schedule_retry_(now); + return; + } +#ifdef API_OUTGOING_CONNECTION_HOST + ESP_LOGD(TAG, "Dialing " API_OUTGOING_CONNECTION_HOST ":%u", API_OUTGOING_CONNECTION_PORT); +#else + char host[socket::SOCKADDR_STR_LEN]; + socket::format_sockaddr_to((struct sockaddr *) &addr, addr_len, host); + ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT); +#endif + int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len); + if (err == 0) { + // Immediate success (possible for localhost) + this->handoff_(server, now); + return; + } + if (errno != EINPROGRESS) { + ESP_LOGW(TAG, "Connect failed: %d", errno); + this->schedule_retry_(now); + return; + } + this->state_ = DialState::DIAL_STATE_CONNECTING; + this->state_ts_ = now; + this->last_poll_ = now; +} + +void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { + if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connect timeout"); + this->schedule_retry_(now); + return; + } + if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) { + return; + } + this->last_poll_ = now; + int err = 0; + switch (socket::poll_connect(*this->dial_socket_, err)) { + case socket::ConnectPollResult::CONNECT_POLL_RESULT_PENDING: + break; + case socket::ConnectPollResult::CONNECT_POLL_RESULT_CONNECTED: + this->handoff_(server, now); + break; + case socket::ConnectPollResult::CONNECT_POLL_RESULT_ERROR: + ESP_LOGW(TAG, "Connect failed: %d", err); + this->schedule_retry_(now); + break; + } +} + +void OutgoingConnectionManager::handoff_(APIServer *server, uint32_t now) { + this->dialed_conn_ = server->add_outgoing_client_(std::move(this->dial_socket_)); + if (this->dialed_conn_ == nullptr) { + // Only preconditions (slot limit, key cleared) refuse the handoff; the + // peer is reachable, so do not escalate the backoff + this->schedule_wait_(now, PRECONDITION_RETRY_MS); + return; + } + // Connected; dialed_conn_ gates further dialing until the session settles + this->state_ = DialState::DIAL_STATE_IDLE; +} + +void OutgoingConnectionManager::schedule_wait_(uint32_t now, uint32_t wait) { + this->dial_socket_.reset(); // no-op when the socket was handed off + this->state_ = DialState::DIAL_STATE_WAITING; + this->state_ts_ = now; + this->wait_ = wait; +} + +void OutgoingConnectionManager::schedule_retry_(uint32_t now) { + // +/-20% jitter so a fleet of devices does not retry one server in lockstep + const uint32_t jitter_span = this->backoff_ / 5; + this->schedule_wait_(now, this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1))); + this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS); +} + +void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_authenticated) { + if (conn != this->dialed_conn_) { + return; + } + this->dialed_conn_ = nullptr; + if (was_authenticated) { + // A working peer (e.g. a host: target that never sends the flag) + // disconnected normally; state is IDLE, so loop() applies the delay + this->backoff_ = BACKOFF_MIN_MS; + } else { + this->schedule_retry_(App.get_loop_component_start_time()); + } +} + +void OutgoingConnectionManager::on_target_client(APIConnection *conn) { + // The target is connected; stop any dial in flight and reset the backoff. + // A dialed connection stays tracked unless it is this one: an inbound + // target must not orphan a still-open dial. + this->dial_socket_.reset(); + if (conn == this->dialed_conn_) { + this->dialed_conn_ = nullptr; + } + this->state_ = DialState::DIAL_STATE_IDLE; + this->backoff_ = BACKOFF_MIN_MS; +#ifndef API_OUTGOING_CONNECTION_HOST + SavedOutgoingTarget target{}; + if (!peer_to_target(conn, target)) { + ESP_LOGW(TAG, "Not remembering this target; its address cannot be dialed"); + return; + } + if (this->host_persisted_ && memcmp(&target, &this->saved_, sizeof(target)) == 0) { + return; // unchanged and already on flash; avoid flash wear + } + // Use the fresh address this boot even if the flash write fails; a failed + // write is retried on the next flagged hello via host_persisted_ + this->saved_ = target; + if (!this->persist_target_()) { + ESP_LOGW(TAG, "Failed to save target"); + return; + } + char host[socket::SOCKADDR_STR_LEN]; + this->format_target_(host); + ESP_LOGD(TAG, "Remembered %s as the dial target", host); +#endif +} + +void OutgoingConnectionManager::dump_config() const { + // The boot delay differs from delay: on deep sleep builds, so print the + // value that actually applies + ESP_LOGCONFIG(TAG, + " Outgoing connection port: %u\n" + " Outgoing connection boot delay: %" PRIu32 "ms", + API_OUTGOING_CONNECTION_PORT, BOOT_WAIT_MS); + // Both forms keep their text out of RAM on ESP8266: in the format string, + // or through LOG_STR_LITERAL +#ifdef API_OUTGOING_CONNECTION_HOST + ESP_LOGCONFIG(TAG, " Outgoing connection host: " API_OUTGOING_CONNECTION_HOST); +#else + char buf[socket::SOCKADDR_STR_LEN]; + this->format_target_(buf); + ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", buf[0] == '\0' ? LOG_STR_LITERAL("none remembered yet") : buf); +#endif +} + +} // namespace esphome::api +#endif // USE_API && USE_API_OUTGOING_CONNECTION diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h new file mode 100644 index 00000000000..16d50c34255 --- /dev/null +++ b/esphome/components/api/api_outgoing_connection.h @@ -0,0 +1,120 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION) + +#ifndef USE_API_NOISE +#error "api outgoing_connection needs noise encryption so the peer is verified by key" +#endif + +#include "esphome/components/socket/socket.h" +#include "esphome/core/preferences.h" + +#include + +namespace esphome::api { + +class APIServer; +class APIConnection; + +// Room for an IPv6 address in every build, so a remembered IPv4 target is +// still dialed after enable_ipv6 is turned on. A size that followed the build +// would also shift every preference registered after this one on ESP8266, +// where slots are positional. An IPv6 target on a build without IPv6 is +// dropped by target_sockaddr_() and relearned. +// Bytes in an IPv6 address +static constexpr size_t TARGET_ADDR_LEN = 16; + +struct SavedOutgoingTarget { + // 0 when none is remembered, else AF_INET or AF_INET6 + uint8_t family; + // Network order, IPv4 in the first four bytes and the rest zero + uint8_t addr[TARGET_ADDR_LEN]; + // Interface a link-local IPv6 target is reachable on, 0 when it needs none. + // Free in flash: the record still rounds up to the same five words. + uint8_t scope_id; +} PACKED; // NOLINT + +/// Dials out when no dial-back target client is connected. Only the TCP +/// direction flips: the device stays the Noise responder, so both sides +/// still verify by key. Targets the YAML host or the last remembered client. +class OutgoingConnectionManager { + public: + void setup(); + void loop(APIServer *server); + /// A key-verified client declared itself a dial-back target; last one wins + void on_target_client(APIConnection *conn); + /// Clears the dialed-connection gate; dying unauthenticated escalates the backoff + void on_client_removed(APIConnection *conn, bool was_authenticated); + void on_shutdown() { this->dial_socket_.reset(); } + void dump_config() const; + + protected: + enum class DialState : uint8_t { + DIAL_STATE_IDLE, + DIAL_STATE_WAITING, + DIAL_STATE_CONNECTING, + }; + + static constexpr uint32_t BACKOFF_MIN_MS = 5000; + static constexpr uint32_t BACKOFF_MAX_MS = 300000; + static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; + static constexpr uint32_t NETWORK_RETRY_MS = 500; + static constexpr uint32_t PRECONDITION_RETRY_MS = 5000; + // A deep sleep wake window is too short to spend on the delay, so those + // builds dial out as soon as the target is gone +#ifdef USE_DEEP_SLEEP + static constexpr uint32_t BOOT_WAIT_MS = 0; + static constexpr uint32_t IDLE_WAIT_MS = BACKOFF_MIN_MS; +#else + static constexpr uint32_t BOOT_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; + static constexpr uint32_t IDLE_WAIT_MS = API_OUTGOING_CONNECTION_DELAY; +#endif + + void try_dial_(APIServer *server, uint32_t now); + void poll_connect_(APIServer *server, uint32_t now); + // Hand the connected socket to the server and gate on the new connection + void handoff_(APIServer *server, uint32_t now); + // Close any half-open dial and wait a jittered backoff before retrying + void schedule_retry_(uint32_t now); + // Wait without escalating the backoff (used for unmet preconditions) + void schedule_wait_(uint32_t now, uint32_t wait); + /// Fill addr with the target and return its length, or 0 when there is none + socklen_t target_sockaddr_(struct sockaddr_storage *addr) const; +#ifndef API_OUTGOING_CONNECTION_HOST + // Write saved_ to flash, tracking success in host_persisted_ + bool persist_target_() { + this->host_persisted_ = this->target_pref_.save(&this->saved_) && global_preferences->sync(); + return this->host_persisted_; + } + /// Format the remembered target for a log line; empty when there is none + void format_target_(std::span buf) const; +#endif + + // Pointers first (4 bytes each on 32-bit) + std::unique_ptr dial_socket_; + // Compared only, never dereferenced + APIConnection *dialed_conn_{nullptr}; +#ifndef API_OUTGOING_CONNECTION_HOST + ESPPreferenceObject target_pref_; +#endif + + // 4-byte types + uint32_t backoff_{BACKOFF_MIN_MS}; + uint32_t wait_{BOOT_WAIT_MS}; + uint32_t state_ts_{0}; + uint32_t last_poll_{0}; + + // Byte-aligned types last +#ifndef API_OUTGOING_CONNECTION_HOST + SavedOutgoingTarget saved_{}; + // False while saved_ holds a value the flash write failed for; retried on + // the next flagged hello + bool host_persisted_{false}; +#endif + DialState state_{DialState::DIAL_STATE_WAITING}; +}; + +} // namespace esphome::api +#endif // USE_API && USE_API_OUTGOING_CONNECTION diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 4180fe10296..b9203cdf25f 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7,1388 +7,1408 @@ 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; - default: - return false; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.api_version_minor = value.as_varint(); + break; +#ifdef USE_API_OUTGOING_CONNECTION + case proto_tag(4, WIRE_TYPE_VARINT): + msg.outgoing_connection_target = value.as_bool(); + break; +#endif } - 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 *__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); +uint8_t *HelloResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); +uint8_t *DisconnectRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *AreaInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *DeviceInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *SerialProxyInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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, 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, 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, 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, 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 +#ifdef USE_API_OUTGOING_CONNECTION + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, msg.api_outgoing_connection_supported); #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 +#ifdef USE_API_OUTGOING_CONNECTION + size += ProtoSize::calc_bool(2, msg.api_outgoing_connection_supported); #endif return size; } #ifdef USE_BLUETOOTH_PROXY -uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - 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); +uint8_t *BluetoothProxyCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); +uint8_t *VoiceAssistantCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ZWaveProxyCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 #ifdef USE_STORE_YAML -uint8_t *StoreYamlCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->supported); +uint8_t *StoreYamlCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.supported); return pos; } -uint32_t StoreYamlCapabilities::calculate_size() const { +uint32_t StoreYamlCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->supported); + size += ProtoSize::calc_bool(1, msg.supported); return size; } #endif -uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_API_WIZARD +uint8_t *WizardCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.configured); + return pos; +} +uint32_t WizardCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_bool(1, msg.configured); + return size; +} +#endif +uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); #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, 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, 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, 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, 4, it); } #endif +#ifdef USE_API_WIZARD + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wizard); +#endif #ifdef USE_STORE_YAML - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 5, this->store_yaml); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.store_yaml); #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 +#ifdef USE_API_WIZARD + size += ProtoSize::calc_message(1, msg.wizard.calculate_size()); +#endif #ifdef USE_STORE_YAML - size += ProtoSize::calc_message(1, this->store_yaml.calculate_size()); + size += ProtoSize::calc_message(1, msg.store_yaml.calculate_size()); #endif return size; } +#ifdef USE_API_WIZARD +uint8_t *DeviceWizardResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + return pos; +} +uint32_t DeviceWizardResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_length(1, msg.data_len); + return size; +} +#endif +#ifdef USE_API_WIZARD_INPUTS +void WizardInputSetRequest::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.entity_id = StringRef(value.data(), value.size()); + break; + } +} +#endif #ifdef USE_BINARY_SENSOR -uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - 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); +uint8_t *ListEntitiesBinarySensorResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BinarySensorStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesCoverResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *CoverStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesFanResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *FanStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesLightResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *LightStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesSensorResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 { - 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); +SensorStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesSwitchResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *SwitchStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesTextSensorResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *TextSensorStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 { - 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_); +SubscribeLogsResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); +uint8_t *NoiseEncryptionSetKeyResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *HomeassistantServiceMap::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *HomeassistantActionRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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, 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, 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, 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 *__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); +uint8_t *SubscribeHomeAssistantStateResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *ListEntitiesServicesArgument::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesServicesResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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, 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); @@ -1399,1114 +1419,1063 @@ 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 *__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); +uint8_t *ExecuteServiceResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesCameraResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *CameraImageResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesClimateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ClimateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesWaterHeaterResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *WaterHeaterStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesNumberResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *NumberStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesSelectResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *SelectStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesSirenResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *SirenStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesLockResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *LockStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesButtonResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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, 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 *__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); +uint8_t *MediaPlayerStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 { - 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); +BluetoothLERawAdvertisementsResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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); @@ -2517,1830 +2486,1918 @@ 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 *__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); +uint8_t *BluetoothDeviceConnectionResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BluetoothGATTDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BluetoothGATTCharacteristic::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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, 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 *__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); +uint8_t *BluetoothGATTService::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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, 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 *__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); +uint8_t *BluetoothGATTGetServicesResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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, 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 *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); +uint8_t *BluetoothGATTGetServicesDoneResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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_); +uint8_t *BluetoothGATTReadResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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_); +uint8_t *BluetoothGATTNotifyDataResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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) { +uint8_t *BluetoothConnectionsFreeResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BluetoothGATTErrorResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BluetoothGATTWriteResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BluetoothGATTNotifyResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BluetoothDevicePairingResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BluetoothDeviceUnpairingResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *BluetoothDeviceClearCacheResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *BluetoothScannerStateResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *VoiceAssistantAudioSettings::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *VoiceAssistantRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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, 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 *__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); +uint8_t *VoiceAssistantAudio::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); +uint8_t *VoiceAssistantAnnounceFinished::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *VoiceAssistantWakeWord::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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; +uint8_t *VoiceAssistantConfigurationResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + for (auto &it : msg.available_wake_words) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 1, it); } - return true; -} -uint8_t *VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - 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 (const auto &it : *msg.active_wake_words) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } - for (const auto &it : *this->active_wake_words) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, it, true); - } - 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 *__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); +uint8_t *ListEntitiesAlarmControlPanelResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *AlarmControlPanelStateResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesTextResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *TextStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesDateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *DateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesTimeResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *TimeStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesEventResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *EventResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesValveResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *ValveStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesDateTimeResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *DateTimeStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesUpdateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *UpdateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); +ZWaveProxyFrame::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ZWaveProxyRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *ZWaveProxyRequestResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *ListEntitiesInfraredResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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) { +#ifdef USE_IR_RF +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 { - uint8_t *__restrict__ pos = buffer.get_pos(); +InfraredRFReceiveEvent::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); #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); } } return size; } -#endif -#ifdef USE_RADIO_FREQUENCY -uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - 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); -#ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->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)); +uint8_t *InfraredRFTransmitCompleteResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, 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::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.success); return pos; } -uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const { +uint32_t InfraredRFTransmitCompleteResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); - size += 5; - size += 2 + this->name.size(); -#ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; -#endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->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 += 5; + size += ProtoSize::calc_bool(1, msg.success); + return size; +} +#endif +#ifdef USE_RADIO_FREQUENCY +uint8_t *ListEntitiesRadioFrequencyResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); +#endif + 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 + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); +#endif + 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::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += 2 + msg.object_id.size(); + size += 5; + size += 2 + msg.name.size(); +#ifdef USE_ENTITY_ICON + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; +#endif + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; +#ifdef USE_DEVICES + size += ProtoSize::calc_uint32(1, msg.device_id); +#endif + 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 { - 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_); +SerialProxyDataReceived::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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)); +uint8_t *SerialProxyGetModemPinsResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 *__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); +uint8_t *SerialProxyRequestResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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; } -bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +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 2: - this->mode = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); break; - default: - return false; } - return true; +} +uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.vendor_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + return pos; +} +uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.vendor_id); + size += ProtoSize::calc_uint32(1, msg.product_id); + size += ProtoSize::calc_uint32(1, msg.bcd_device); + size += ProtoSize::calc_uint32(1, msg.interface_number); + return size; +} +uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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.source)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.manufacturer); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.product); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.serial_number); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.usb); + return pos; +} +uint32_t SerialProxyIdentity::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.instance); + size += msg.source ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.flags); + size += ProtoSize::calc_length(1, msg.manufacturer.size()); + size += ProtoSize::calc_length(1, msg.product.size()); + size += ProtoSize::calc_length(1, msg.serial_number.size()); + size += ProtoSize::calc_message(1, msg.usb.calculate_size()); + return size; } #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 *__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); +uint8_t *BluetoothSetConnectionParamsResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + 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 #ifdef USE_STORE_YAML -uint8_t *GetYamlResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->data_len_); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->data_ptr_, this->data_len_); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, this->done ? 0x01 : 0x00); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->total_size); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->encoding); +uint8_t *GetYamlResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len_); + pos = ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_ptr_, msg.data_len_); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, msg.done ? 0x01 : 0x00); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.total_size); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.encoding); return pos; } -uint32_t GetYamlResponse::calculate_size() const { +uint32_t GetYamlResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length_force(1, this->data_len_); + size += ProtoSize::calc_length_force(1, msg.data_len_); size += ProtoSize::calc_bool_force(1); - size += ProtoSize::calc_uint32(1, this->total_size); - size += !this->encoding.empty() ? 2 + this->encoding.size() : 0; + size += ProtoSize::calc_uint32(1, msg.total_size); + size += !msg.encoding.empty() ? 2 + msg.encoding.size() : 0; 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_API_WIZARD_INPUTS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#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 +#ifdef USE_IR_RF +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 f2fa31c3534..d3d2d0d02f0 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -23,6 +23,7 @@ enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, SERIAL_PROXY_PORT_TYPE_RS485 = 2, + SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3, }; enum EntityCategory : uint32_t { ENTITY_CATEGORY_NONE = 0, @@ -371,7 +372,17 @@ enum SerialProxyMode : uint32_t { SERIAL_PROXY_MODE_RAW = 0, SERIAL_PROXY_MODE_PROTOCOL = 1, }; +enum SerialProxyIdentitySource : uint32_t { + SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0, + SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1, + SERIAL_PROXY_IDENTITY_SOURCE_USB = 2, +}; #endif +enum SerialProxyIdentityFlag : uint32_t { + SERIAL_PROXY_IDENTITY_FLAG_NONE = 0, + SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1, + SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2, +}; } // namespace enums @@ -382,7 +393,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 @@ -417,20 +428,25 @@ class CommandProtoMessage : public ProtoDecodableMessage { class HelloRequest final : public ProtoDecodableMessage { public: static constexpr uint16_t MESSAGE_TYPE = 1; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("hello_request"); } #endif StringRef client_info{}; uint32_t api_version_major{0}; uint32_t api_version_minor{0}; +#ifdef USE_API_OUTGOING_CONNECTION + bool outgoing_connection_target{false}; +#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); }; class HelloResponse final : public ProtoMessage { public: @@ -443,8 +459,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -459,14 +479,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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: @@ -512,8 +539,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -527,8 +558,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -539,11 +574,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -554,7 +593,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 312; + static constexpr uint16_t ESTIMATED_SIZE = 315; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -613,8 +652,15 @@ 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; +#ifdef USE_API_OUTGOING_CONNECTION + bool api_outgoing_connection_supported{false}; +#endif + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -626,8 +672,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -639,8 +689,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -653,8 +707,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -666,8 +724,29 @@ class ZWaveProxyCapabilities final : public ProtoMessage { class StoreYamlCapabilities final : public ProtoMessage { public: bool supported{false}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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: +}; +#endif +#ifdef USE_API_WIZARD +class WizardCapabilities final : public ProtoMessage { + public: + bool configured{false}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -678,7 +757,7 @@ class StoreYamlCapabilities final : public ProtoMessage { class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 119; + static constexpr uint8_t ESTIMATED_SIZE = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -694,17 +773,68 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; #endif +#ifdef USE_API_WIZARD + WizardCapabilities wizard{}; +#endif #ifdef USE_STORE_YAML StoreYamlCapabilities store_yaml{}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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: }; +#ifdef USE_API_WIZARD +class DeviceWizardResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 157; + static constexpr uint8_t ESTIMATED_SIZE = 19; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("device_wizard_response"); } +#endif + const uint8_t *data{nullptr}; + uint16_t data_len{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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: +}; +#endif +#ifdef USE_API_WIZARD_INPUTS +class WizardInputSetRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 158; + static constexpr uint8_t ESTIMATED_SIZE = 14; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("wizard_input_set_request"); } +#endif + uint32_t key{0}; + StringRef entity_id{}; + 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 class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 19; @@ -726,10 +856,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -745,8 +879,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -765,10 +903,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -785,8 +927,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -805,13 +951,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 @@ -827,8 +975,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -846,9 +998,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -872,14 +1028,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 @@ -894,8 +1051,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -920,9 +1081,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -962,14 +1127,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 @@ -980,13 +1146,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1002,8 +1172,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1020,9 +1194,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 +1210,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1053,13 +1236,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 @@ -1070,9 +1255,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1086,10 +1275,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1106,12 +1299,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: @@ -1127,8 +1323,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1145,12 +1345,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: @@ -1160,8 +1363,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1172,10 +1379,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1189,7 +1400,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{}; @@ -1201,10 +1412,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1227,13 +1442,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 @@ -1244,11 +1461,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1265,12 +1486,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 { @@ -1294,12 +1518,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: @@ -1307,13 +1534,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: @@ -1325,27 +1554,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1359,15 +1594,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1391,9 +1630,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: @@ -1416,9 +1653,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 @@ -1431,13 +1666,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1453,8 +1692,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1475,8 +1718,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1492,12 +1739,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 @@ -1527,8 +1777,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1538,7 +1792,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 @@ -1550,13 +1804,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1590,14 +1849,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 @@ -1614,8 +1874,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1625,7 +1889,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 @@ -1635,8 +1899,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1656,13 +1925,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 @@ -1676,11 +1947,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1696,8 +1971,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1712,13 +1991,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 @@ -1729,9 +2010,13 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP 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; + const std::span *options{}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1745,10 +2030,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1763,14 +2052,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 @@ -1784,8 +2074,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1800,8 +2094,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1823,14 +2121,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 @@ -1844,9 +2143,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1861,8 +2164,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1879,14 +2186,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 @@ -1897,9 +2205,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1913,25 +2225,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1947,8 +2265,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1965,8 +2287,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -1988,14 +2314,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 @@ -2007,12 +2334,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: @@ -2036,8 +2366,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2057,12 +2391,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: @@ -2075,8 +2412,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2091,20 +2432,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2118,8 +2466,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2132,8 +2484,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2149,8 +2505,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2165,8 +2525,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2182,12 +2546,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: @@ -2204,8 +2571,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2224,13 +2595,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: @@ -2241,12 +2614,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: @@ -2259,13 +2635,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: @@ -2277,12 +2655,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: @@ -2299,8 +2680,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2317,8 +2702,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2335,8 +2724,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2352,8 +2745,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2369,8 +2766,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2387,8 +2788,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2405,8 +2810,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2423,8 +2832,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2443,8 +2856,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2459,12 +2876,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 @@ -2477,20 +2897,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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,12 +2932,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2526,23 +2957,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: @@ -2553,13 +2990,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: @@ -2573,15 +3012,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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: @@ -2596,13 +3041,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: @@ -2615,13 +3062,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: @@ -2631,8 +3080,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2641,11 +3094,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2661,13 +3118,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: @@ -2677,12 +3136,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: @@ -2694,8 +3156,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2710,12 +3176,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 @@ -2729,8 +3198,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2745,8 +3218,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2762,14 +3239,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 @@ -2782,10 +3260,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2799,10 +3281,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2817,14 +3303,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 @@ -2835,8 +3322,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2854,8 +3345,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2872,13 +3367,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 @@ -2889,8 +3386,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2908,8 +3409,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2926,13 +3431,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 @@ -2943,10 +3450,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2960,9 +3471,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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,12 +3493,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -2999,8 +3518,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3017,13 +3540,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 @@ -3034,8 +3559,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3051,8 +3580,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3067,13 +3600,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 @@ -3084,9 +3619,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 +3643,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3125,13 +3668,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 @@ -3144,14 +3689,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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: @@ -3163,15 +3715,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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: @@ -3182,8 +3740,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3201,8 +3763,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3210,7 +3776,7 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { protected: }; #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { public: static constexpr uint16_t MESSAGE_TYPE = 136; @@ -3228,14 +3794,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: @@ -3249,8 +3816,36 @@ 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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 InfraredRFTransmitCompleteResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 153; + static constexpr uint8_t ESTIMATED_SIZE = 11; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_complete_response"); } +#endif +#ifdef USE_DEVICES + uint32_t device_id{0}; +#endif + uint32_t key{0}; + bool success{false}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3270,8 +3865,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3293,12 +3892,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: @@ -3314,8 +3916,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3332,13 +3938,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: @@ -3349,12 +3957,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: @@ -3364,12 +3975,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: @@ -3381,8 +3995,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3398,12 +4016,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: @@ -3415,9 +4036,13 @@ 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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3433,12 +4058,59 @@ class SerialProxySetModeRequest final : public ProtoDecodableMessage { #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); +}; +class UsbDeviceDescriptor final : public ProtoMessage { + public: + uint32_t vendor_id{0}; + uint32_t product_id{0}; + uint32_t bcd_device{0}; + uint32_t interface_number{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 SerialProxyIdentity final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 155; + static constexpr uint8_t ESTIMATED_SIZE = 54; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_identity"); } +#endif + uint32_t instance{0}; + enums::SerialProxyIdentitySource source{}; + uint32_t flags{0}; + StringRef manufacturer{nullptr, 0}; // null until set, encode only + StringRef product{nullptr, 0}; // null until set, encode only + StringRef serial_number{nullptr, 0}; // null until set, encode only + UsbDeviceDescriptor usb{}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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; }; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS @@ -3454,12 +4126,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: @@ -3470,8 +4145,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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 @@ -3482,7 +4161,7 @@ class BluetoothSetConnectionParamsResponse final : public ProtoMessage { #ifdef USE_STORE_YAML class GetYamlResponse final : public ProtoMessage { public: - static constexpr uint16_t MESSAGE_TYPE = 155; + static constexpr uint16_t MESSAGE_TYPE = 160; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("get_yaml_response"); } @@ -3495,9 +4174,13 @@ class GetYamlResponse final : public ProtoMessage { } bool done{false}; uint32_t total_size{0}; - StringRef encoding{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef encoding{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() 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 2a5e13200eb..af9e32760a4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -143,6 +143,8 @@ template<> const char *proto_enum_to_string(enums::S return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232"); case enums::SERIAL_PROXY_PORT_TYPE_RS485: return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485"); + case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL: + return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -890,13 +892,40 @@ template<> const char *proto_enum_to_string(enums::Seria return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyIdentitySource value) { + switch (value) { + case enums::SERIAL_PROXY_IDENTITY_SOURCE_NONE: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_NONE"); + case enums::SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED"); + case enums::SERIAL_PROXY_IDENTITY_SOURCE_USB: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_USB"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif +template<> const char *proto_enum_to_string(enums::SerialProxyIdentityFlag value) { + switch (value) { + case enums::SERIAL_PROXY_IDENTITY_FLAG_NONE: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_NONE"); + case enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_CONNECTED"); + case enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_ERROR"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} const char *HelloRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("HelloRequest")); dump_field(out, ESPHOME_PSTR("client_info"), this->client_info); dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major); dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor); +#ifdef USE_API_OUTGOING_CONNECTION + dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target); +#endif return out.c_str(); } const char *HelloResponse::dump_to(DumpBuffer &out) const { @@ -1020,6 +1049,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif #ifdef USE_API_NOISE dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + dump_field(out, ESPHOME_PSTR("api_outgoing_connection_supported"), this->api_outgoing_connection_supported); #endif return out.c_str(); } @@ -1053,6 +1085,13 @@ const char *StoreYamlCapabilities::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif +#ifdef USE_API_WIZARD +const char *WizardCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("WizardCapabilities")); + dump_field(out, ESPHOME_PSTR("configured"), this->configured); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1077,6 +1116,11 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { out.append("\n"); } #endif +#ifdef USE_API_WIZARD + out.append(2, ' ').append_p(ESPHOME_PSTR("wizard")).append(": "); + this->wizard.dump_to(out); + out.append("\n"); +#endif #ifdef USE_STORE_YAML out.append(2, ' ').append_p(ESPHOME_PSTR("store_yaml")).append(": "); this->store_yaml.dump_to(out); @@ -1084,6 +1128,21 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { #endif return out.c_str(); } +#ifdef USE_API_WIZARD +const char *DeviceWizardResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceWizardResponse")); + dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len); + return out.c_str(); +} +#endif +#ifdef USE_API_WIZARD_INPUTS +const char *WizardInputSetRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("WizardInputSetRequest")); + dump_field(out, ESPHOME_PSTR("key"), this->key); + dump_field(out, ESPHOME_PSTR("entity_id"), this->entity_id); + return out.c_str(); +} +#endif const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); @@ -1354,6 +1413,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 { @@ -1696,6 +1756,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 { @@ -1763,6 +1824,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 { @@ -2723,7 +2785,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest")); #ifdef USE_DEVICES @@ -2752,6 +2814,15 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const { } return out.c_str(); } +const char *InfraredRFTransmitCompleteResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitCompleteResponse")); +#ifdef USE_DEVICES + dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); +#endif + dump_field(out, ESPHOME_PSTR("key"), this->key); + dump_field(out, ESPHOME_PSTR("success"), this->success); + return out.c_str(); +} #endif #ifdef USE_RADIO_FREQUENCY const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const { @@ -2835,6 +2906,27 @@ const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); return out.c_str(); } +const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("UsbDeviceDescriptor")); + dump_field(out, ESPHOME_PSTR("vendor_id"), this->vendor_id); + dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); + dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); + dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + return out.c_str(); +} +const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyIdentity")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("source"), static_cast(this->source)); + dump_field(out, ESPHOME_PSTR("flags"), this->flags); + dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer); + dump_field(out, ESPHOME_PSTR("product"), this->product); + dump_field(out, ESPHOME_PSTR("serial_number"), this->serial_number); + out.append(2, ' ').append_p(ESPHOME_PSTR("usb")).append(": "); + this->usb.dump_to(out); + out.append("\n"); + 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_includes.h b/esphome/components/api/api_pb2_includes.h index 70ba579fcc3..833e6529e2a 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -28,6 +28,7 @@ // Standard library includes that might be needed #include +#include #include #include diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index d9e8fec4508..08965611b89 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -628,7 +628,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: { InfraredRFTransmitRawTimingsRequest msg; msg.decode(msg_data, msg_size); @@ -723,8 +723,37 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif +#ifdef USE_SERIAL_PROXY + case 154 /* SubscribeSerialProxyIdentityRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_subscribe_serial_proxy_identity_request")); +#endif + this->on_subscribe_serial_proxy_identity_request(); + break; + } +#endif +#ifdef USE_API_WIZARD + case 156 /* DeviceWizardRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_device_wizard_request")); +#endif + this->on_device_wizard_request(); + break; + } +#endif +#ifdef USE_API_WIZARD_INPUTS + case WizardInputSetRequest::MESSAGE_TYPE: { + WizardInputSetRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_wizard_input_set_request"), msg); +#endif + this->on_wizard_input_set_request(msg); + break; + } +#endif #ifdef USE_STORE_YAML - case 154 /* GetYamlRequest is empty */: { + case 159 /* GetYamlRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_get_yaml_request")); #endif diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index a47acfac1b1..cf8b92e011a 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -29,6 +29,13 @@ class APIServerConnectionBase { void on_device_capabilities_request(){}; +#ifdef USE_API_WIZARD + void on_device_wizard_request(){}; +#endif + +#ifdef USE_API_WIZARD_INPUTS + void on_wizard_input_set_request(const WizardInputSetRequest &value){}; +#endif void on_list_entities_request(){}; void on_subscribe_states_request(){}; @@ -213,7 +220,7 @@ class APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; #endif @@ -238,6 +245,10 @@ class APIServerConnectionBase { #ifdef USE_SERIAL_PROXY void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_subscribe_serial_proxy_identity_request(){}; +#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..33869dd976e 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,22 +28,47 @@ 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) { ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno); this->destroy_socket_(); +#ifdef USE_API_OUTGOING_CONNECTION + // Dial-out needs no listener; degrade instead of stopping the component + this->status_set_error(LOG_STR("listen socket failed")); +#else this->mark_failed(); +#endif } 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()) { @@ -53,43 +77,6 @@ void APIServer::setup() { #endif #endif - this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections - if (this->socket_ == nullptr) { - this->socket_failed_(LOG_STR("creation")); - return; - } - int enable = 1; - int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); - if (err != 0) { - ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno); - // we can still continue - } - err = this->socket_->setblocking(false); - if (err != 0) { - this->socket_failed_(LOG_STR("nonblocking")); - return; - } - - struct sockaddr_storage server; - - socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); - if (sl == 0) { - this->socket_failed_(LOG_STR("set sockaddr")); - return; - } - - err = this->socket_->bind((struct sockaddr *) &server, sl); - if (err != 0) { - this->socket_failed_(LOG_STR("bind")); - return; - } - - err = this->socket_->listen(this->listen_backlog_); - if (err != 0) { - this->socket_failed_(LOG_STR("listen")); - return; - } - #ifdef USE_LOGGER if (logger::global_logger != nullptr) { logger::global_logger->add_log_callback( @@ -135,6 +122,47 @@ void APIServer::setup() { if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); } +#ifdef USE_API_OUTGOING_CONNECTION + this->outgoing_conn_.setup(); +#endif + + // Listener last: on failure socket_failed_() returns early, and an + // outgoing_connection build keeps dialing out without one + this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections + if (this->socket_ == nullptr) { + this->socket_failed_(LOG_STR("creation")); + return; + } + int enable = 1; + int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + if (err != 0) { + ESP_LOGW(TAG, "Socket reuseaddr: errno %d", errno); + // we can still continue + } + err = this->socket_->setblocking(false); + if (err != 0) { + this->socket_failed_(LOG_STR("nonblocking")); + return; + } + + struct sockaddr_storage server; + + socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->port_); + if (sl == 0) { + this->socket_failed_(LOG_STR("set sockaddr")); + return; + } + + err = this->socket_->bind((struct sockaddr *) &server, sl); + if (err != 0) { + this->socket_failed_(LOG_STR("bind")); + return; + } + + err = this->socket_->listen(this->listen_backlog_); + if (err != 0) { + this->socket_failed_(LOG_STR("listen")); + } } void APIServer::loop() { @@ -143,6 +171,19 @@ void APIServer::loop() { this->accept_new_connections_(); } + const bool connected = network::is_connected(); +#ifdef USE_NOISE_SPARE_EPHEMERAL + if (connected && !noise::has_spare_ephemeral()) { + this->refill_spare_ephemeral_(); + } +#endif + +#ifdef USE_API_OUTGOING_CONNECTION + if (!this->shutting_down_) { + this->outgoing_conn_.loop(this); + } +#endif + if (this->api_connection_count_ == 0) { // Check reboot timeout - done in loop to avoid scheduler heap churn // (cancelled scheduler items sit in heap memory until their scheduled time). @@ -159,8 +200,7 @@ void APIServer::loop() { } // Process clients and remove disconnected ones in a single pass - // Check network connectivity once for all clients - if (!network::is_connected()) { + if (!connected) { // Network is down - disconnect all clients for (auto &client : this->active_clients()) { client->on_fatal_error(); @@ -188,6 +228,19 @@ void APIServer::loop() { } } +#ifdef USE_NOISE_SPARE_EPHEMERAL +// An OTA handshake is not visible here and just pays the refill it triggered +void APIServer::refill_spare_ephemeral_() { + const uint32_t now = App.get_loop_component_start_time(); + for (auto &client : this->active_clients()) { + if (client->is_still_connecting(now)) { + return; + } + } + noise::prepare_spare_ephemeral(); +} +#endif + void APIServer::remove_client_(uint8_t client_index) { auto &client = this->clients_[client_index]; @@ -203,6 +256,15 @@ void APIServer::remove_client_(uint8_t client_index) { std::string client_peername(client->get_peername_to(peername_buf)); #endif + // Read before the swap-and-reset below destroys the connection + const bool was_authenticated = client->is_authenticated(); +#ifdef USE_API_OUTGOING_CONNECTION + if (client->flags_.outgoing_connection_target) { + this->outgoing_target_count_--; + } + this->outgoing_conn_.on_client_removed(client.get(), was_authenticated); +#endif + // Close socket now (was deferred from on_fatal_error to allow getpeername) client->helper_->close(); @@ -221,9 +283,15 @@ void APIServer::remove_client_(uint8_t client_index) { // Last client disconnected - set warning and start tracking for reboot timeout // (suppressed while provisioning is pending - see loop()). + // Refresh on every authenticated removal, not just the last one, so an + // unauthenticated straggler removed later (e.g. a port scan, or a dial to + // a host that accepts TCP but never speaks the API) cannot discard a + // healthy session's timestamp and trigger a spurious reboot + if (was_authenticated) { + this->last_connected_ = App.get_loop_component_start_time(); + } if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); - this->last_connected_ = App.get_loop_component_start_time(); } #ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER @@ -245,7 +313,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { sock->getpeername_to(peername); // Check if we're at the connection limit - if (this->api_connection_count_ >= MAX_API_CONNECTIONS) { + if (this->at_client_limit_()) { ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername); // Immediately close - socket destructor will handle cleanup sock.reset(); @@ -254,18 +322,47 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { ESP_LOGD(TAG, "Accept %s", peername); - auto *conn = new APIConnection(std::move(sock), this); - this->clients_[this->api_connection_count_++].reset(conn); - conn->start(); - - // First client connected - clear warning and update timestamp - if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { - this->status_clear_warning(); - this->last_connected_ = App.get_loop_component_start_time(); - } + this->add_client_(std::move(sock)); } } +APIConnection *APIServer::add_client_(std::unique_ptr sock) { + auto *conn = new APIConnection(std::move(sock), this); // NOLINT(cppcoreguidelines-owning-memory) + this->clients_[this->api_connection_count_++].reset(conn); + conn->start(); + + // First client connected - clear warning. The reboot watchdog timestamp is + // refreshed when an authenticated client is removed (see remove_client_), + // never on bare TCP connects. + if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { + this->status_clear_warning(); + } + return conn; +} + +#ifdef USE_API_OUTGOING_CONNECTION +APIConnection *APIServer::add_outgoing_client_(std::unique_ptr sock) { + // Re-check at the handoff: inbound clients may have taken the last slot and + // the PSK may have been cleared since the dial started (mark_outgoing() + // needs the noise helper) + const bool at_limit = this->at_client_limit_(); + if (at_limit || !this->noise_ctx_.has_psk()) { + ESP_LOGW(TAG, "Dropping outgoing connection (%s)", + at_limit ? LOG_STR_LITERAL("max connections") : LOG_STR_LITERAL("no key")); + return nullptr; + } + auto *conn = this->add_client_(std::move(sock)); + // After start(): sends our server hello first so the peer can pick the key + conn->mark_outgoing(); + return conn; +} + +void APIServer::on_outgoing_target_client(APIConnection *conn) { + this->outgoing_target_count_++; + this->outgoing_conn_.on_target_client(conn); +} +#endif + void APIServer::dump_config() { char addr_buf[network::USE_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, @@ -282,6 +379,9 @@ void APIServer::dump_config() { #else ESP_LOGCONFIG(TAG, " Noise encryption: NO"); #endif +#ifdef USE_API_OUTGOING_CONNECTION + this->outgoing_conn_.dump_config(); +#endif } void APIServer::handle_disconnect(APIConnection *conn) {} @@ -404,7 +504,16 @@ void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +void APIServer::send_serial_proxy_identity(const SerialProxyIdentity &msg) { + for (auto &c : this->active_clients()) { + if (c->flags_.serial_proxy_identity_subscription) + c->send_serial_proxy_identity(msg); + } +} +#endif + +#ifdef USE_IR_RF void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key, const std::vector *timings) { InfraredRFReceiveEvent resp{}; @@ -417,6 +526,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_ for (auto &c : this->active_clients()) c->send_infrared_rf_receive_event(resp); } + #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -433,8 +543,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")); } @@ -577,6 +688,8 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString if (!c->send_message(req)) { API_LOG_MSG_DROPPED(TAG, "Disconnect request"); } + // Force it: a session from before the key was active must not survive + c->flags_.next_close = true; } }); } @@ -678,6 +791,9 @@ void APIServer::on_shutdown() { // Close the listening socket to prevent new connections this->destroy_socket_(); +#ifdef USE_API_OUTGOING_CONNECTION + this->outgoing_conn_.on_shutdown(); +#endif // Change batch delay to 5ms for quick flushing during shutdown this->batch_delay_ = 5; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e5a22dcef82..09b08de4e3f 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -5,16 +5,17 @@ #include "api_buffer.h" // Must precede clients_ so APIConnection is complete for default_delete (libc++). #include "api_connection.h" -#ifdef USE_API_NOISE +#if defined(USE_API_NOISE) || defined(USE_NOISE_SPARE_EPHEMERAL) // Only present in the build when the noise component is loaded #include "esphome/components/noise/noise.h" #endif #include "api_pb2.h" #include "api_pb2_service.h" +#include "api_outgoing_connection.h" #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 +44,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 @@ -86,61 +91,65 @@ class APIServer final : public Component, void set_noise_psk(const uint8_t *psk) { this->noise_ctx_.set_psk(psk); } noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE +#ifdef USE_API_OUTGOING_CONNECTION + // Called by APIConnection when a client declares itself a dial-back target in its hello + void on_outgoing_target_client(APIConnection *conn); +#endif 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,18 +192,22 @@ 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); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// Tell every subscribed client that a serial proxy port's identity changed + void send_serial_proxy_identity(const SerialProxyIdentity &msg); +#endif +#ifdef USE_IR_RF void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings); #endif @@ -263,6 +276,16 @@ class APIServer final : public Component, protected: // Accept incoming socket connections. Only called when socket has pending connections. void __attribute__((noinline)) accept_new_connections_(); + /// Takes the socket into a new connection and starts it; callers must have + /// checked at_client_limit_() first + APIConnection *add_client_(std::unique_ptr sock); + bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; } +#ifdef USE_API_OUTGOING_CONNECTION + // Returns the new connection, or nullptr (socket dropped) when at the limit + APIConnection *add_outgoing_client_(std::unique_ptr sock); + bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; } + friend class OutgoingConnectionManager; +#endif // Remove a disconnected client by index. Swaps with the last populated slot and resets it. void __attribute__((noinline)) remove_client_(uint8_t client_index); @@ -303,6 +326,8 @@ class APIServer final : public Component, delete this->socket_; this->socket_ = nullptr; } + /// Log the failure, drop the listen socket, and mark the component failed + /// unless this build can still dial out void socket_failed_(const LogString *msg); // Pointers and pointer-like types first (4 bytes each) socket::ListenSocket *socket_{nullptr}; @@ -353,16 +378,21 @@ class APIServer final : public Component, // Group smaller types together 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}; + uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py bool shutting_down_ = false; uint8_t api_connection_count_{0}; +#ifdef USE_API_OUTGOING_CONNECTION + // Connected clients whose hello declared them a dial-back target + uint8_t outgoing_target_count_{0}; +#endif #if defined(USE_PROVISIONING) && defined(USE_API_NOISE) // Index assigned by the provisioning manager for reporting this transport's state. uint8_t provisioning_source_{0}; #endif +#ifdef USE_NOISE_SPARE_EPHEMERAL + void refill_spare_ephemeral_(); +#endif #ifdef USE_API_NOISE noise::NoiseContext noise_ctx_; #ifndef USE_API_NOISE_PSK_FROM_YAML @@ -370,6 +400,9 @@ class APIServer final : public Component, #endif ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE +#ifdef USE_API_OUTGOING_CONNECTION + OutgoingConnectionManager outgoing_conn_; +#endif }; extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/api/api_wizard.cpp b/esphome/components/api/api_wizard.cpp new file mode 100644 index 00000000000..ec270ab4111 --- /dev/null +++ b/esphome/components/api/api_wizard.cpp @@ -0,0 +1,103 @@ +#include "api_wizard.h" + +#ifdef USE_API_WIZARD + +#include + +#include "api_connection.h" +#include "api_pb2.h" +#include "api_server.h" +#include "esphome/core/log.h" + +namespace esphome::api { + +static const char *const TAG = "api.wizard"; + +uint8_t *wizard_encode_response(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + if (msg.data_len == 0) + return pos; + pos = ProtoEncode::encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, 1, 2); // type 2: Length-delimited + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, msg.data_len); + PROTO_ENCODE_CHECK_BOUNDS(pos, msg.data_len); + progmem_memcpy(pos, msg.data, msg.data_len); + return pos + msg.data_len; +} + +#ifdef USE_API_WIZARD_INPUTS +static bool wizard_entity_id_valid(const char *entity_id, size_t length) { + return length > 0 && length < WIZARD_ENTITY_ID_BUFFER_SIZE && memchr(entity_id, '.', length) != nullptr; +} + +const char *wizard_set_input(const WizardInputSetRequest &msg) { + if (!wizard_entity_id_valid(msg.entity_id.c_str(), msg.entity_id.size())) { + ESP_LOGW(TAG, "Ignoring an invalid entity id for wizard input"); + return nullptr; + } + for (size_t i = 0; i < API_WIZARD_INPUT_COUNT; i++) { + // The table is in flash, which ESP8266 can only read through progmem_memcpy + WizardInputEntry entry; + progmem_memcpy(&entry, &API_WIZARD_INPUTS[i], sizeof(entry)); + if (entry.key != msg.key) + continue; + memcpy(entry.entity_id, msg.entity_id.c_str(), msg.entity_id.size()); + entry.entity_id[msg.entity_id.size()] = '\0'; + return entry.entity_id; + } + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input"); + return nullptr; +} +#endif // USE_API_WIZARD_INPUTS + +bool APIConnection::send_device_wizard_response_() { + DeviceWizardResponse resp; + resp.data = API_WIZARD_DATA; + resp.data_len = API_WIZARD_DATA_SIZE; + // Not send_message: the data is in flash, so wizard_encode_response copies it out + return this->send_message_(DeviceWizardResponse::calc_size_msg(&resp), DeviceWizardResponse::MESSAGE_TYPE, + &wizard_encode_response, &resp); +} + +void APIConnection::on_device_wizard_request() { + if (!this->send_device_wizard_response_()) { + this->on_fatal_error(); + } +} + +#ifdef USE_API_WIZARD_INPUTS +void APIConnection::on_wizard_input_set_request(const WizardInputSetRequest &msg) { + const char *entity_id = wizard_set_input(msg); + if (entity_id == nullptr) + return; +#ifdef USE_API_WIZARD_LINKED_INPUTS + // Entities subscribed to the buffer before it held an entity id, so every client needs to learn of it now + for (auto &client : this->parent_->active_clients()) { + client->resend_state_subscriptions(entity_id); + } +#endif +} +#endif + +#ifdef USE_API_WIZARD_LINKED_INPUTS +void APIConnection::resend_state_subscriptions(const char *entity_id) { + if (!this->flags_.home_assistant_states) + return; + for (const auto &it : this->parent_->get_state_subs()) { + if (it.entity_id != entity_id) + continue; + SubscribeHomeAssistantStateResponse resp; + resp.entity_id = StringRef(it.entity_id); + resp.attribute = it.attribute != nullptr ? StringRef(it.attribute) : StringRef(""); + resp.once = it.once; + if (!this->send_message(resp)) { + // Could not send now: send every subscription again from the loop + this->state_subs_at_ = 0; + return; + } + } +} +#endif + +} // namespace esphome::api + +#endif // USE_API_WIZARD diff --git a/esphome/components/api/api_wizard.h b/esphome/components/api/api_wizard.h new file mode 100644 index 00000000000..948b9445f22 --- /dev/null +++ b/esphome/components/api/api_wizard.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_API_WIZARD + +#include +#include + +#include "esphome/core/hal.h" +#include "proto.h" +#include "esphome/core/string_ref.h" + +namespace esphome::api { + +class WizardInputSetRequest; + +/// Size of the buffer holding the entity id of a wizard input. Home Assistant entity ids are at most 255 bytes. +static constexpr size_t WIZARD_ENTITY_ID_BUFFER_SIZE = 256; + +/// The wizard as zstd compressed JSON (see DeviceWizardResponse), built by the generated code +/// (components/api/wizard.py) and kept in flash. API_WIZARD_DATA_SIZE bytes long. +extern const uint8_t API_WIZARD_DATA[] PROGMEM; + +/// Encodes a DeviceWizardResponse like the generated encoder would. The data is in flash, which ESP8266 can only read +/// with progmem_memcpy, so the generated encoder (a plain memcpy) cannot be used. Plain memcpy elsewhere. +uint8_t *wizard_encode_response(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + +#ifdef USE_API_WIZARD_INPUTS +/// Where the entity id of an input is kept, found by the key the client uses for it. +struct WizardInputEntry { + uint32_t key; // FNV-1 hash of the ESPHome id of the input + char *entity_id; // RAM buffer of WIZARD_ENTITY_ID_BUFFER_SIZE bytes, shared with the homeassistant entity +}; + +/// The inputs of the wizard, in flash. API_WIZARD_INPUT_COUNT entries long. +extern const WizardInputEntry API_WIZARD_INPUTS[] PROGMEM; + +/// Apply a WizardInputSetRequest: validate it and copy the entity id into the input's buffer. Nothing is stored +/// across restarts, so the client sends the choices again after every connection. +/// Returns the buffer, or nullptr when the request was ignored. +const char *wizard_set_input(const WizardInputSetRequest &msg); +#endif + +#ifdef USE_API_WIZARD_STANDALONE_INPUTS +/// An input of the wizard that is not tied to an entity of the device. The entity ID the user picks is only ever read +/// by lambdas, for example `id(input).entity_id()`. It lives in the same RAM buffer a linked input uses. +class WizardInput { + public: + explicit WizardInput(const char *entity_id) : entity_id_(entity_id) {} + /// The Home Assistant entity ID, empty until the wizard sets one. + StringRef entity_id() const { return StringRef(this->entity_id_); } + bool has_entity_id() const { return this->entity_id_[0] != '\0'; } + + protected: + const char *entity_id_; +}; +#endif + +} // namespace esphome::api + +#endif // USE_API_WIZARD diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index 9e0faf98819..e7798a5d267 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -4,64 +4,40 @@ #ifdef USE_API #ifdef USE_API_HOMEASSISTANT_SERVICES #include +#include +#include #include -#include #include "api_pb2.h" #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #include "esphome/components/json/json_util.h" #endif #include "esphome/core/automation.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome::api { -template class TemplatableStringValue : public TemplatableValue { - // Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation) - // rather than being wrapped in a lambda. The base class constructor for const char* is more - // specialized than the templated constructor here, so it should be selected. - static_assert(std::is_constructible_v, const char *>, - "Base class must have const char* constructor for STATIC_STRING optimization"); +// Converts a lambda result to the string sent to Home Assistant +template +requires(!std::is_pointer_v>) std::string field_to_string(T &&val) { + return to_string(std::forward(val)); // NOLINT +} +inline std::string field_to_string(const char *val) { return val ? std::string(val) : std::string(); } +inline std::string field_to_string(std::string val) { return val; } +inline std::string field_to_string(StringRef val) { return val.str(); } - private: - // Helper to convert value to string - handles the case where value is already a string - template static std::string value_to_string(T &&val) { - return to_string(std::forward(val)); // NOLINT +/// A key and value from codegen; on ESP8266 the table and its strings are in flash. +/// The value is the constant `value`, or the result of `fn` when it is set. +template struct HomeAssistantField { + const char *key; + const char *value; + std::string (*fn)(const Ts &...); + + template static constexpr HomeAssistantField from_lambda(const char *key, F /*lambda*/) { + return {key, nullptr, &call_lambda}; } - - // Overloads for string types - needed because std::to_string doesn't support them - static std::string value_to_string(char *val) { - return val ? std::string(val) : std::string(); - } // For lambdas returning char* (e.g., itoa) - static std::string value_to_string(const char *val) { return std::string(val); } // For lambdas returning .c_str() - static std::string value_to_string(const std::string &val) { return val; } - static std::string value_to_string(std::string &&val) { return std::move(val); } - static std::string value_to_string(const StringRef &val) { return val.str(); } - static std::string value_to_string(StringRef &&val) { return val.str(); } - - public: - TemplatableStringValue() : TemplatableValue() {} - - template::value, int> = 0> - TemplatableStringValue(F value) : TemplatableValue(value) {} - - template::value, int> = 0> - TemplatableStringValue(F f) - : TemplatableValue([f](X... x) -> std::string { return value_to_string(f(x...)); }) {} -}; - -template class TemplatableKeyValuePair { - public: - // Default constructor needed for FixedVector::emplace_back() - TemplatableKeyValuePair() = default; - - // Keys are always string literals from YAML dictionary keys (e.g., "code", "event") - // and never templatable values or lambdas. Only the value parameter can be a lambda/template. - // Using const char* avoids std::string heap allocation - keys remain in flash. - template TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {} - - const char *key{nullptr}; - TemplatableStringValue value; + template static std::string call_lambda(const Ts &...x) { return field_to_string(F{}(x...)); } }; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -106,45 +82,20 @@ template using ActionResponseCallback = std::function class HomeAssistantServiceCallAction final : public Action { public: - explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) { + using Field = HomeAssistantField; + + /// `fields` is a codegen table: the action or event name (no key), then the data, data_template + /// and variables entries. + HomeAssistantServiceCallAction(APIServer *parent, bool is_event, const Field *fields, uint8_t data_count, + uint8_t data_template_count, uint8_t variables_count) + : parent_(parent), + fields_(fields), + data_count_(data_count), + data_template_count_(data_template_count), + variables_count_(variables_count) { this->flags_.is_event = is_event; } - template void set_service(T service) { this->service_ = service; } - - // Initialize FixedVector members - called from Python codegen with compile-time known sizes. - // Must be called before any add_* methods; capacity must match the number of subsequent add_* calls. - void init_data(size_t count) { this->data_.init(count); } - void init_data_template(size_t count) { this->data_template_.init(count); } - void init_variables(size_t count) { this->variables_.init(count); } - - // Keys are always string literals from the Python code generation (e.g., cg.add(var.add_data("tag_id", templ))). - // The value parameter can be a lambda/template, but keys are never templatable. - // Using const char* for keys avoids std::string heap allocation - keys remain in flash. - template void add_data(const char *key, V &&value) { - this->add_kv_(this->data_, key, std::forward(value)); - } - template void add_data_template(const char *key, V &&value) { - this->add_kv_(this->data_template_, key, std::forward(value)); - } - template void add_variable(const char *key, V &&value) { - this->add_kv_(this->variables_, key, std::forward(value)); - } - -#ifdef USE_ESP8266 - // On ESP8266, ESPHOME_F() returns __FlashStringHelper* (PROGMEM pointer). - // Store as const char* — populate_service_map copies from PROGMEM at play() time. - template void add_data(const __FlashStringHelper *key, V &&value) { - this->add_kv_(this->data_, reinterpret_cast(key), std::forward(value)); - } - template void add_data_template(const __FlashStringHelper *key, V &&value) { - this->add_kv_(this->data_template_, reinterpret_cast(key), std::forward(value)); - } - template void add_variable(const __FlashStringHelper *key, V &&value) { - this->add_kv_(this->variables_, reinterpret_cast(key), std::forward(value)); - } -#endif - #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES template void set_response_template(T response_template) { this->response_template_ = response_template; @@ -162,19 +113,61 @@ template class HomeAssistantServiceCallAction final : public Act #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES void play(const Ts &...x) override { + const Field *fields = this->fields_; + const size_t total = 1 + this->data_count_ + this->data_template_count_ + this->variables_count_; + + // Lambda results, and on ESP8266 the RAM copies of the flash strings, must live until the send + size_t lambda_count = 0; +#ifdef USE_ESP8266 + size_t flash_len = 0; +#endif + for (size_t i = 0; i < total; i++) { + lambda_count += fields[i].fn != nullptr; +#ifdef USE_ESP8266 + if (fields[i].fn == nullptr) + flash_len += ESPHOME_strlen_P(fields[i].value); + if (fields[i].key != nullptr) + flash_len += ESPHOME_strlen_P(fields[i].key); +#endif + } + FixedVector results; + results.init(lambda_count); +#ifdef USE_ESP8266 + SmallBufferWithHeapFallback<128, char> flash_copy(flash_len); + char *cursor = flash_copy.get(); +#endif + auto string_ref = [&](const char *str) { +#ifdef USE_ESP8266 + size_t len = ESPHOME_strlen_P(str); + memcpy_P(cursor, str, len); + StringRef ref(cursor, len); + cursor += len; + return ref; +#else + return StringRef(str); +#endif + }; + auto value_ref = [&](const Field &field) { + if (field.fn == nullptr) + return string_ref(field.value); + results.push_back(field.fn(x...)); + return StringRef(results.back()); + }; + auto fill = [&](FixedVector &dest, uint8_t count) { + dest.init(count); + for (uint8_t i = 0; i < count; i++, fields++) { + auto &kv = dest.emplace_back(); + kv.key = string_ref(fields->key); + kv.value = value_ref(*fields); + } + }; + HomeassistantActionRequest resp; - std::string service_value = this->service_.value(x...); - resp.service = StringRef(service_value); + resp.service = value_ref(*fields++); resp.is_event = this->flags_.is_event; - - // Local storage for lambda-evaluated strings - lives until after send - FixedVector data_storage; - FixedVector data_template_storage; - FixedVector variables_storage; - - this->populate_service_map(resp.data, this->data_, data_storage, x...); - this->populate_service_map(resp.data_template, this->data_template_, data_template_storage, x...); - this->populate_service_map(resp.variables, this->variables_, variables_storage, x...); + fill(resp.data, this->data_count_); + fill(resp.data_template, this->data_template_count_); + fill(resp.variables, this->variables_count_); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON @@ -223,90 +216,26 @@ template class HomeAssistantServiceCallAction final : public Act } protected: - // Helper to add key-value pairs to FixedVectors - // Keys are always string literals (const char*), values can be lambdas/templates - template void add_kv_(FixedVector> &vec, const char *key, V &&value) { - auto &kv = vec.emplace_back(); - kv.key = key; - kv.value = std::forward(value); - } - - template - static void populate_service_map(VectorType &dest, SourceType &source, FixedVector &value_storage, - Ts... x) { - dest.init(source.size()); - -#ifdef USE_ESP8266 - // On ESP8266, all static strings from codegen are FLASH_STRING (PROGMEM), - // so is_static_string() is always false — the zero-copy STATIC_STRING fast - // path from the non-ESP8266 branch cannot trigger. We copy all keys and - // values unconditionally: keys via _P functions (may be in PROGMEM), values - // via value() which handles FLASH_STRING internally. - value_storage.init(source.size() * 2); - - for (auto &it : source) { - auto &kv = dest.emplace_back(); - - // Key: copy from possible PROGMEM - { - size_t key_len = strlen_P(it.key); - value_storage.push_back(std::string(key_len, '\0')); - memcpy_P(value_storage.back().data(), it.key, key_len); - kv.key = StringRef(value_storage.back()); - } - - // Value: value() handles FLASH_STRING via _P functions internally - value_storage.push_back(it.value.value(x...)); - kv.value = StringRef(value_storage.back()); - } -#else - // On non-ESP8266, strings are directly readable from flash-mapped memory. - // Count non-static strings to allocate exact storage needed. - size_t lambda_count = 0; - for (const auto &it : source) { - if (!it.value.is_static_string()) { - lambda_count++; - } - } - value_storage.init(lambda_count); - - for (auto &it : source) { - auto &kv = dest.emplace_back(); - kv.key = StringRef(it.key); - - if (it.value.is_static_string()) { - // Static string — pointer directly readable, zero allocation - kv.value = StringRef(it.value.get_static_string()); - } else { - // Lambda — evaluate and store result - value_storage.push_back(it.value.value(x...)); - kv.value = StringRef(value_storage.back()); - } - } -#endif - } - APIServer *parent_; - TemplatableStringValue service_{}; - FixedVector> data_; - FixedVector> data_template_; - FixedVector> variables_; -#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES -#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - TemplatableStringValue response_template_{""}; - Trigger success_trigger_with_response_; -#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - Trigger success_trigger_; - Trigger error_trigger_; -#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES - + const Field *fields_; + uint8_t data_count_; + uint8_t data_template_count_; + uint8_t variables_count_; struct Flags { uint8_t is_event : 1; uint8_t wants_status : 1; uint8_t wants_response : 1; uint8_t has_response_template : 1; - uint8_t reserved : 5; + uint8_t reserved : 4; } flags_{0}; +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON + TemplatableValue response_template_{}; + Trigger success_trigger_with_response_; +#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON + Trigger success_trigger_; + Trigger error_trigger_; +#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES }; } // namespace esphome::api diff --git a/esphome/components/api/proto.cpp b/esphome/components/api/proto.cpp index 236e4a474a1..6738d5d8b71 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -119,7 +119,7 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size } // Single-pass encode for repeated submessage elements (non-template core). -// Writes field tag, reserves 1 byte for length varint, encodes the submessage body, +// Reserves 1 byte for length varint, encodes the submessage body, // then backpatches the actual length. For the common case (body < 128 bytes), this is // just a single byte write with no memmove — all current repeated submessage types // (BLE advertisements at ~47B, GATT descriptors at ~24B, service args, etc.) take @@ -143,51 +143,34 @@ uint32_t ProtoDecodableMessage::count_repeated_field(const uint8_t *buffer, size // // After writing 2-byte varint at len_pos: // [tag][v1][v2][body ..... body] -// ^-- pos_ = element end, within buffer -void ProtoWriteBuffer::encode_sub_message(uint32_t field_id, const void *value, - uint8_t *(*encode_fn)(const void *, - ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { - this->encode_field_raw(field_id, 2); - // Reserve 1 byte for length varint (optimistic: submessage < 128 bytes) - uint8_t *len_pos = this->pos_; - this->debug_check_bounds_(1); - this->pos_++; - uint8_t *body_start = this->pos_; - this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); - uint32_t body_size = static_cast(this->pos_ - body_start); - if (body_size < 128) [[likely]] { +// ^-- returned cursor = element end, within buffer +uint8_t *ProtoEncode::encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, const void *value, + ProtoEncodeFn encode_fn) { + // Reserve 1 byte for the length varint (optimistic: submessage < 128 bytes) + uint8_t *len_pos = pos; + PROTO_ENCODE_CHECK_BOUNDS(pos, 1); + uint8_t *body_start = pos + 1; + uint8_t *after_body = encode_fn(value, body_start PROTO_ENCODE_DEBUG_ARG); + uint32_t body_size = static_cast(after_body - body_start); + if (body_size < VARINT_MAX_1_BYTE) [[likely]] { // Common case: 1-byte varint, just backpatch *len_pos = static_cast(body_size); - return; + return after_body; } - // Compute extra bytes needed for varint beyond the 1 already reserved + // Shift the body forward to make room for the extra length varint bytes uint8_t extra = ProtoSize::varint(body_size) - 1; - // Shift body forward to make room for the extra varint bytes - this->debug_check_bounds_(extra); + PROTO_ENCODE_CHECK_BOUNDS(after_body, extra); std::memmove(body_start + extra, body_start, body_size); - uint8_t *end = this->pos_ + extra; // Write the full varint at len_pos - this->pos_ = len_pos; - this->encode_varint_raw(body_size); - this->pos_ = end; + (void) encode_varint_raw_loop(len_pos PROTO_ENCODE_DEBUG_ARG, body_size); + return after_body + extra; } // Non-template core for encode_optional_sub_message. -void ProtoWriteBuffer::encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value, - uint8_t *(*encode_fn)(const void *, - ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)) { - if (nested_size == 0) - return; - this->encode_field_raw(field_id, 2); - this->encode_varint_raw(nested_size); -#ifdef ESPHOME_DEBUG_API - uint8_t *start = this->pos_; - this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); - if (static_cast(this->pos_ - start) != nested_size) - this->debug_check_encode_size_(field_id, nested_size, this->pos_ - start); -#else - this->pos_ = encode_fn(value, *this PROTO_ENCODE_DEBUG_INIT(this->buffer_)); -#endif +uint8_t *ProtoEncode::encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t nested_size, const void *value, ProtoEncodeFn encode_fn) { + pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, nested_size); + return encode_fn(value, pos PROTO_ENCODE_DEBUG_ARG); } #ifdef ESPHOME_DEBUG_API @@ -195,6 +178,20 @@ 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 proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end) { + ptrdiff_t actual = end - (len_pos + ProtoSize::varint(expected)); + if (actual == static_cast(expected)) + return; + ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id, + expected, actual); + 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, @@ -202,85 +199,81 @@ void ProtoWriteBuffer::debug_check_bounds_(size_t bytes, const char *caller) { abort(); } } -void ProtoWriteBuffer::debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual) { - ESP_LOGE(TAG, "encode_message: size mismatch for field %" PRIu32 ": calculated=%" PRIu32 " actual=%td", field_id, - expected, actual); - abort(); -} #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..5f2a90d5472 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 @@ -55,22 +56,25 @@ inline constexpr int64_t decode_zigzag64(uint64_t value) { return (value & 1) ? static_cast(~(value >> 1)) : static_cast(value >> 1); } -/// Count number of varints in a packed buffer -inline uint16_t count_packed_varints(const uint8_t *data, size_t len) { - uint16_t count = 0; - while (len > 0) { - // Skip varint bytes until we find one without continuation bit - while (len > 0 && (*data & 0x80)) { - data++; - len--; - } - if (len > 0) { - data++; - len--; - count++; +/// Count varints in a packed buffer: len minus bytes with the continuation bit, summed a word at a time. +/// Word is a template parameter so tests can cover the 32-bit path on a 64-bit host. +template inline uint16_t count_packed_varints(const uint8_t *data, size_t len) { + constexpr size_t word_size = sizeof(Word); + constexpr Word lane_ones = ~Word{0} / 0xFF; // 0x01..01 + const uint8_t *end = data + len; + size_t continuations = 0; + while (data != end) { + // Unaligned word loads fault on Xtensa + if ((reinterpret_cast(data) & (word_size - 1)) == 0 && static_cast(end - data) >= word_size) { + Word word; + memcpy(&word, __builtin_assume_aligned(data, word_size), word_size); + continuations += (((word >> 7) & lane_ones) * lane_ones) >> (word_size * 8 - 8); + data += word_size; + } else { + continuations += *data++ >> 7; } } - return count; + return static_cast(len - continuations); } /// Encode a varint directly into a pre-allocated buffer. @@ -170,40 +174,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 +228,11 @@ 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); +/// Aborts unless a sized sub-message (length prefix at len_pos) ended where its calculated size said. +void proto_check_sub_message_size(uint32_t field_id, uint32_t expected, const uint8_t *len_pos, const uint8_t *end); #else #define PROTO_ENCODE_DEBUG_PARAM #define PROTO_ENCODE_DEBUG_ARG @@ -252,22 +264,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); } - /// 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); - /// Encode an optional singular submessage field — skips if empty. - /// Thin template wrapper; all buffer work is in the non-template core. - template void encode_optional_sub_message(uint32_t field_id, const T &value); - - // NOLINTBEGIN(readability-identifier-naming) - // Non-template core for encode_sub_message — backpatch approach. - void encode_sub_message(uint32_t field_id, const void *value, - uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)); - // Non-template core for encode_optional_sub_message. - void encode_optional_sub_message(uint32_t field_id, uint32_t nested_size, const void *value, - uint8_t *(*encode_fn)(const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM)); - // NOLINTEND(readability-identifier-naming) + void encode_field_raw(uint32_t field_id, uint32_t type) { this->encode_varint_raw(proto_tag(field_id, type)); } APIBuffer *get_buffer() const { return buffer_; } uint8_t *get_pos() const { return pos_; } void set_pos(uint8_t *pos) { pos_ = pos; } @@ -278,7 +275,6 @@ class ProtoWriteBuffer { #ifdef ESPHOME_DEBUG_API void debug_check_bounds_(size_t bytes, const char *caller = __builtin_FUNCTION()); - void debug_check_encode_size_(uint32_t field_id, uint32_t expected, ptrdiff_t actual); #else void debug_check_bounds_([[maybe_unused]] size_t bytes) {} #endif @@ -287,19 +283,34 @@ 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. +/// Generated encode body: writes the fields at pos, returns the cursor past them. +using ProtoEncodeFn = uint8_t *(*) (const void *, uint8_t *PROTO_ENCODE_DEBUG_PARAM); + +/// 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 +318,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 +375,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 +415,205 @@ 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)); + } + /// Repeated sub-message element; the constant tag is written inline. template - static inline void 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(); + [[nodiscard]] static inline uint8_t *encode_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const T &value) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); + return encode_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, &value, &T::encode_msg); } + /// Singular sub-message field, skipped when it encodes to nothing. 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) { - buffer.set_pos(pos); - buffer.encode_optional_sub_message(field_id, value); - pos = buffer.get_pos(); + [[nodiscard]] static inline uint8_t *encode_optional_sub_message(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const T &value) { + uint32_t nested_size = T::calc_size_msg(&value); + if (nested_size == 0) + return pos; + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); +#ifdef ESPHOME_DEBUG_API + uint8_t *end = encode_sized_sub_message_body(pos PROTO_ENCODE_DEBUG_ARG, nested_size, &value, &T::encode_msg); + proto_check_sub_message_size(field_id, nested_size, pos, end); + return end; +#else + return encode_sized_sub_message_body(pos, nested_size, &value, &T::encode_msg); +#endif + } + /// Length and body, length backpatched after the body is written. + [[nodiscard]] static uint8_t *encode_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + const void *value, ProtoEncodeFn encode_fn); + /// Length and body for a precomputed size. + [[nodiscard]] static uint8_t *encode_sized_sub_message_body(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t nested_size, const void *value, + ProtoEncodeFn encode_fn); + + 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 +705,10 @@ 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, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM) { return 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 +728,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 +746,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 +879,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); } @@ -874,28 +961,6 @@ 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); -} - -// 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_); -} - template const char *proto_enum_to_string(T value); // ProtoService removed — its methods were inlined into APIConnection. 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/api/wizard.py b/esphome/components/api/wizard.py new file mode 100644 index 00000000000..314b907e4d0 --- /dev/null +++ b/esphome/components/api/wizard.py @@ -0,0 +1,461 @@ +"""The device wizard of the api component: schema, validation and code generation. + +Home Assistant shows the wizard when the device is added. See api_wizard.h for the C++ side. +""" + +from collections.abc import Callable +import importlib +import json +from types import ModuleType +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_DESCRIPTION +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_ID, + CONF_INTERNAL, + CONF_NAME, + CONF_PAGES, + CONF_PLATFORM, + CONF_TARGET, +) +from esphome.core import CORE, ID +import esphome.final_validate as fv +from esphome.helpers import fnv1_hash, fnv1_hash_object_id, fnv1a_32bit_hash +from esphome.types import ConfigType + +API_DOMAIN = "api" + +CONF_ENTITIES = "entities" +CONF_ENTITY = "entity" +CONF_INPUTS = "inputs" +CONF_INTEGRATION = "integration" +CONF_SUPPORTED_FEATURES = "supported_features" +CONF_TITLE = "title" +CONF_WIZARD = "wizard" + +_API = cg.esphome_ns.namespace("api") +WizardInput = _API.class_("WizardInput") + +WIZARD_ENTITY_ID_BUFFER_SIZE = ( + 256 # api_wizard.h; Home Assistant entity IDs are at most 255 bytes +) +# One API message must fit APIBuffer::MAX_SIZE (65535) together with the largest frame header (7 bytes, +# Noise) and footer (16 bytes, Noise MAC) +WIZARD_RESPONSE_MAX_SIZE = 65535 - 7 - 16 +# Version of the JSON the wizard is sent as, see wizard_document() +WIZARD_JSON_VERSION = 1 +# zstd level the JSON is compressed with. Output is deterministic for a given zstd version. +WIZARD_ZSTD_LEVEL = 19 + +# Wizard string limits; api.proto documents the same values +WIZARD_TITLE_MAX_LENGTH = 127 +WIZARD_DESCRIPTION_MAX_LENGTH = 255 +WIZARD_FILTER_MAX_LENGTH = 63 +WIZARD_SUPPORTED_FEATURE_MAX_LENGTH = 127 + + +def _wizard_text(max_length: int) -> Callable[[Any], str]: + """A string passed to Home Assistant verbatim, so it may be a [%key:...%] translation placeholder.""" + return cv.All(cv.string_strict, cv.Length(max=max_length)) + + +def _wizard_strings(max_length: int) -> Callable[[Any], list[str]]: + """A single string or a list of strings, always validated to a non-empty list.""" + return cv.All( + cv.ensure_list(cv.All(cv.string_strict, cv.Length(min=1, max=max_length))), + cv.Length(min=1), + ) + + +# Mirrors Home Assistant's EntityFilterSelectorConfig +WIZARD_ENTITY_FILTER_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_INTEGRATION): cv.All( + cv.string_strict, cv.Length(min=1, max=WIZARD_FILTER_MAX_LENGTH) + ), + cv.Optional(CONF_DOMAIN): _wizard_strings(WIZARD_FILTER_MAX_LENGTH), + cv.Optional(CONF_DEVICE_CLASS): _wizard_strings(WIZARD_FILTER_MAX_LENGTH), + cv.Optional(CONF_SUPPORTED_FEATURES): _wizard_strings( + WIZARD_SUPPORTED_FEATURE_MAX_LENGTH + ), + } + ), + cv.has_at_least_one_key( + CONF_INTEGRATION, CONF_DOMAIN, CONF_DEVICE_CLASS, CONF_SUPPORTED_FEATURES + ), +) + +WIZARD_ENTITY_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(cg.EntityBase), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + } +) + + +# An input is either standalone (id declares a new WizardInput) or linked to a homeassistant entity that has no +# entity_id of its own: Home Assistant sets it +WIZARD_INPUT_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_ID): cv.declare_id(WizardInput), + cv.Optional(CONF_ENTITY): cv.use_id(cg.EntityBase), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_TARGET): cv.Schema( + { + cv.Required(CONF_ENTITY): cv.All( + cv.ensure_list(WIZARD_ENTITY_FILTER_SCHEMA), cv.Length(min=1) + ), + } + ), + } + ), + cv.has_exactly_one_key(CONF_ID, CONF_ENTITY), +) + +WIZARD_PAGE_SCHEMA = cv.All( + cv.Schema( + { + cv.Optional(CONF_TITLE): _wizard_text(WIZARD_TITLE_MAX_LENGTH), + cv.Optional(CONF_DESCRIPTION): _wizard_text(WIZARD_DESCRIPTION_MAX_LENGTH), + cv.Optional(CONF_ENTITIES): cv.All( + cv.ensure_list(WIZARD_ENTITY_SCHEMA), cv.Length(min=1) + ), + cv.Optional(CONF_INPUTS): cv.All( + cv.ensure_list(WIZARD_INPUT_SCHEMA), cv.Length(min=1) + ), + } + ), + cv.has_at_least_one_key(CONF_ENTITIES, CONF_INPUTS), +) + + +def _wizard_inputs(wizard: ConfigType) -> list[ConfigType]: + return [conf for page in wizard[CONF_PAGES] for conf in page.get(CONF_INPUTS, [])] + + +def _wizard_input_id(conf: ConfigType) -> ID: + """The ID that names an input: the one a standalone input declares, or its linked entity.""" + return conf[CONF_ID] if CONF_ID in conf else conf[CONF_ENTITY] + + +def _validate_unique_wizard_inputs(wizard: ConfigType) -> ConfigType: + """An input is identified on the wire by a hash of its ID, so IDs and hashes must be unique.""" + seen: dict[int, str] = {} + for conf in _wizard_inputs(wizard): + input_id = _wizard_input_id(conf).id + if (key := fnv1_hash(input_id)) in seen: + if seen[key] == input_id: + raise cv.Invalid(f"Wizard input '{input_id}' is used more than once") + raise cv.Invalid( + f"Wizard inputs '{seen[key]}' and '{input_id}' have the same hash, rename one" + ) + seen[key] = input_id + return wizard + + +WIZARD_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_PAGES): cv.All( + cv.ensure_list(WIZARD_PAGE_SCHEMA), cv.Length(min=1) + ), + } + ), + _validate_unique_wizard_inputs, +) + +# Platforms of the homeassistant component that a wizard input can stand for +WIZARD_INPUT_DOMAINS = ( + "binary_sensor", + "button", + "number", + "select", + "sensor", + "switch", + "text", + "text_sensor", +) +# Platforms that act on one family of Home Assistant domains, which the input's filters must stay within +WIZARD_DOMAIN_LIMITED_PLATFORMS = ("button", "number", "select", "switch", "text") + + +def wizard_input_ids(api_config: ConfigType) -> set[str]: + """The IDs of the entities that are linked inputs of the wizard in the given api config.""" + if (wizard := api_config.get(CONF_WIZARD)) is None: + return set() + return { + conf[CONF_ENTITY].id for conf in _wizard_inputs(wizard) if CONF_ENTITY in conf + } + + +def _wizard_buffer_name(entity_id: ID) -> str: + return f"api_wizard_input_{entity_id.id}" + + +def wizard_input_buffer(entity_id: ID) -> str | None: + """Name of the RAM buffer holding the Home Assistant entity ID of a wizard input. + + Returns None when the entity is not an input of the wizard. The buffer is defined by the api + codegen, and the homeassistant entity of the input is given it in place of a constant. + """ + if entity_id.id not in wizard_input_ids(CORE.config.get(API_DOMAIN, {})): + return None + return _wizard_buffer_name(entity_id) + + +def _wizard_input_declaration( + config: fv.FinalValidateConfig, entity_id: ID +) -> tuple[str, ConfigType]: + """The domain (like sensor) and the config an input ID is declared in.""" + path = config.get_path_for_id(entity_id)[:-1] + return path[0], config.get_config_for_path(path) + + +def _wizard_input_filters( + conf: ConfigType, config: fv.FinalValidateConfig +) -> list[ConfigType]: + """The entity filters of an input, with the defaults of a domain limited linked platform.""" + if (filters := conf.get(CONF_TARGET, {}).get(CONF_ENTITY)) is not None: + return filters + if CONF_ENTITY in conf: + domain, _ = _wizard_input_declaration(config, conf[CONF_ENTITY]) + if domains := _wizard_default_domains(domain): + return [{CONF_DOMAIN: domains}] + return [] + + +def _wizard_defines(wizard: ConfigType) -> set[str]: + """The defines for the parts of the wizard the configuration uses, so the rest is not compiled.""" + inputs = _wizard_inputs(wizard) + defines = {"USE_API_WIZARD"} + if inputs: + defines.add("USE_API_WIZARD_INPUTS") + if any(CONF_ENTITY in conf for conf in inputs): + defines.add("USE_API_WIZARD_LINKED_INPUTS") + if any(CONF_ID in conf for conf in inputs): + defines.add("USE_API_WIZARD_STANDALONE_INPUTS") + return defines + + +def _wizard_default_domains(domain: str) -> list[str] | None: + """The domains Home Assistant entities can be picked from when the input sets no target.""" + if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS: + platform = importlib.import_module(f"esphome.components.homeassistant.{domain}") + return list(platform.SUPPORTED_DOMAINS) + return None + + +def _validate_wizard_input(conf: ConfigType) -> ConfigType: + if CONF_ENTITY not in conf: + return conf + domain, declaration = _wizard_input_declaration( + fv.full_config.get(), conf[CONF_ENTITY] + ) + if ( + declaration.get(CONF_PLATFORM) != "homeassistant" + or domain not in WIZARD_INPUT_DOMAINS + ): + raise cv.Invalid( + f"Wizard input '{conf[CONF_ENTITY].id}' must be a homeassistant " + f"{', '.join(WIZARD_INPUT_DOMAINS)} entity" + ) + if CONF_ENTITY_ID in declaration: + # An entity_id in the configuration is a static entry, which is kept apart from the dynamic ones + raise cv.Invalid( + f"'{conf[CONF_ENTITY].id}' has an entity_id set in its configuration, so it cannot be a " + "wizard input. Remove entity_id to let Home Assistant set it through the wizard." + ) + if domain in WIZARD_DOMAIN_LIMITED_PLATFORMS: + supported = _wizard_default_domains(domain) + for entity_filter in conf.get(CONF_TARGET, {}).get(CONF_ENTITY, []): + if not (domains := entity_filter.get(CONF_DOMAIN)): + raise cv.Invalid( + f"Every filter of a homeassistant {domain} input must set domain" + ) + if unsupported := [d for d in domains if d not in supported]: + raise cv.Invalid( + f"The homeassistant {domain} does not support the domain(s) " + f"{', '.join(unsupported)}. Supported: {', '.join(supported)}" + ) + return conf + + +def _validate_wizard_entity_exposed(value: ID) -> ID: + """Reject entities that are internal, as they are not exposed over the API, or have no name. + + The key a client knows an entity by is a hash of its name. Without a name of its own, the + device works the name out at runtime from its friendly name, which can add the MAC address, + so the key cannot be known when the wizard is built. + """ + _, declaration = _wizard_input_declaration(fv.full_config.get(), value) + if declaration.get(CONF_INTERNAL, False): + raise cv.Invalid( + f"Entity '{value.id}' is internal, so it is not exposed over the API " + "and cannot be used in the wizard" + ) + if not declaration.get(CONF_NAME): + raise cv.Invalid( + f"Entity '{value.id}' has no name of its own, so its key is not known " + "when the wizard is built. Give it a name to use it in the wizard" + ) + return value + + +_WIZARD_FINAL_VALIDATE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WIZARD): { + cv.Optional(CONF_PAGES): [ + { + cv.Optional(CONF_ENTITIES): [ + {cv.Optional(CONF_ID): _validate_wizard_entity_exposed} + ], + cv.Optional(CONF_INPUTS): [_validate_wizard_input], + } + ] + } + }, + extra=cv.ALLOW_EXTRA, +) + + +def final_validate(config: ConfigType) -> None: + """Final validation of the wizard in the given api config, if it has one.""" + _WIZARD_FINAL_VALIDATE_SCHEMA(config) + if (wizard := config.get(CONF_WIZARD)) is not None: + size = len(wizard_blob(wizard, fv.full_config.get())) + if size > WIZARD_RESPONSE_MAX_SIZE: + raise cv.Invalid( + f"The compressed wizard is {size} bytes, {size - WIZARD_RESPONSE_MAX_SIZE} " + f"bytes over the {WIZARD_RESPONSE_MAX_SIZE} bytes one API message can hold. " + "Shorten the texts or use fewer pages, entities or filters", + path=[CONF_WIZARD], + ) + + +WIZARD_INPUT_IS_SET_SCHEMA = cv.maybe_simple_value( + {cv.Required(CONF_ID): cv.use_id(WizardInput)}, key=CONF_ID +) + +# Only for standalone inputs: a linked input is read through its homeassistant entity +automation.register_apply_condition( + "api.wizard.input_is_set", WIZARD_INPUT_IS_SET_SCHEMA, "has_entity_id()" +) + + +def zstd_module() -> ModuleType: + """The zstd module: the standard library one from Python 3.14, otherwise the backport.""" + try: + return importlib.import_module("compression.zstd") + except ImportError: + return importlib.import_module("backports.zstd") + + +def _entity_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + """An entity of the device that the page shows, keyed as ListEntitiesResponse keys it.""" + _, declaration = _wizard_input_declaration(config, conf[CONF_ID]) + document: ConfigType = {"key": fnv1_hash_object_id(declaration[CONF_NAME])} + if (device := declaration.get(CONF_DEVICE_ID)) is not None: + document["device_id"] = fnv1a_32bit_hash(device.id) + if description := conf.get(CONF_DESCRIPTION): + document[CONF_DESCRIPTION] = description + return document + + +def _input_document(conf: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + document: ConfigType = {"key": fnv1_hash(_wizard_input_id(conf).id)} + if description := conf.get(CONF_DESCRIPTION): + document[CONF_DESCRIPTION] = description + if filters := _wizard_input_filters(conf, config): + document["entity_filters"] = [dict(entity_filter) for entity_filter in filters] + return document + + +def wizard_document(wizard: ConfigType, config: fv.FinalValidateConfig) -> ConfigType: + """The wizard as the JSON document the device sends, before it is serialised. + + This is the format Home Assistant reads, and api.proto documents it for clients. Version 1: + + {"version": 1, + "pages": [{"title": "...", "description": "...", + "entities": [{"key": 123, "device_id": 456, "description": "..."}], + "inputs": [{"key": 789, "description": "...", + "entity_filters": [{"integration": "...", "domain": ["..."], + "device_class": ["..."], "supported_features": ["..."]}]}]}]} + + Anything empty or unset, and every empty list, is left out. Strings are passed through as + written, so they may be Home Assistant translation placeholders. + + - An entity key is the key ListEntitiesResponse sends for the entity: the FNV-1 hash of the + object id made from its name (entity_helpers). device_id is the hash of the ESPHome id of + the device it belongs to (esphome/core/config.py), and is left out for the main device. + - An input key is the FNV-1 hash of the ESPHome id of the input, or of the linked entity. A linked + entity must not set entity_id, as that is a static entry kept apart from the ones the wizard sets. + - entity_filters are the filters of the input, or the default filters of a linked switch, + number, text, select or button. + """ + pages: list[ConfigType] = [] + for page in wizard[CONF_PAGES]: + document: ConfigType = {} + for key in (CONF_TITLE, CONF_DESCRIPTION): + if value := page.get(key): + document[key] = value + if entities := [ + _entity_document(e, config) for e in page.get(CONF_ENTITIES, []) + ]: + document[CONF_ENTITIES] = entities + if inputs := [_input_document(i, config) for i in page.get(CONF_INPUTS, [])]: + document[CONF_INPUTS] = inputs + pages.append(document) + return {"version": WIZARD_JSON_VERSION, CONF_PAGES: pages} + + +def wizard_blob(wizard: ConfigType, config: fv.FinalValidateConfig) -> bytes: + """The wizard document as compact, sorted UTF-8 JSON in a single zstd frame.""" + text = json.dumps( + wizard_document(wizard, config), + separators=(",", ":"), + sort_keys=True, + ensure_ascii=False, + ) + return zstd_module().compress(text.encode("utf-8"), level=WIZARD_ZSTD_LEVEL) + + +async def to_code(wizard: ConfigType) -> None: + """Emit the compressed wizard, the table of inputs and the defines. + + The API reads both tables from its own sources, so they are externally linked PROGMEM arrays. + """ + blob = wizard_blob(wizard, CORE.config) + cg.extern_progmem_array("esphome::api::API_WIZARD_DATA", cg.uint8, list(blob)) + cg.add_define("API_WIZARD_DATA_SIZE", len(blob)) + for define in sorted(_wizard_defines(wizard)): + cg.add_define(define) + entries: list[cg.RawExpression] = [] + for conf in _wizard_inputs(wizard): + input_id = _wizard_input_id(conf) + # Every buffer starts empty, until the wizard sets it. A linked homeassistant entity uses it as its entity id. + buffer = _wizard_buffer_name(input_id) + cg.add_global( + cg.RawStatement( + f'static char {buffer}[{WIZARD_ENTITY_ID_BUFFER_SIZE}] = "";' + ) + ) + if CONF_ID in conf: + cg.new_Pvariable(input_id, cg.RawExpression(buffer)) + entries.append(cg.RawExpression(f"{{{fnv1_hash(input_id.id)}u, {buffer}}}")) + if entries: + cg.extern_progmem_array( + "esphome::api::API_WIZARD_INPUTS", + cg.esphome_ns.namespace("api").struct("WizardInputEntry"), + entries, + ) + cg.add_define("API_WIZARD_INPUT_COUNT", len(entries)) diff --git a/esphome/components/as3935/sensor.py b/esphome/components/as3935/sensor.py index b727b8fdb9f..f03213c8ca5 100644 --- a/esphome/components/as3935/sensor.py +++ b/esphome/components/as3935/sensor.py @@ -35,10 +35,6 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_AS3935_ID]) - if distance_config := config.get(CONF_DISTANCE): - sens = await sensor.new_sensor(distance_config) - cg.add(hub.set_distance_sensor(sens)) - - if lightning_energy_config := config.get(CONF_LIGHTNING_ENERGY): - sens = await sensor.new_sensor(lightning_energy_config) - cg.add(hub.set_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISTANCE, hub.set_distance_sensor) + await sensors(CONF_LIGHTNING_ENERGY, hub.set_energy_sensor) 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/sensor.py b/esphome/components/atm90e32/sensor.py index 38b24c7cf67..e3d5fa2de2b 100644 --- a/esphome/components/atm90e32/sensor.py +++ b/esphome/components/atm90e32/sensor.py @@ -241,12 +241,9 @@ async def to_code(config: ConfigType) -> None: if peak_current_config := conf.get(CONF_PEAK_CURRENT): sens = await sensor.new_sensor(peak_current_config) cg.add(var.set_peak_current_sensor(i, sens)) - if frequency_config := config.get(CONF_FREQUENCY): - sens = await sensor.new_sensor(frequency_config) - cg.add(var.set_freq_sensor(sens)) - if chip_temperature_config := config.get(CONF_CHIP_TEMPERATURE): - sens = await sensor.new_sensor(chip_temperature_config) - cg.add(var.set_chip_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREQUENCY, var.set_freq_sensor) + await sensors(CONF_CHIP_TEMPERATURE, var.set_chip_temperature_sensor) cg.add(var.set_line_freq(config[CONF_LINE_FREQUENCY])) cg.add(var.set_current_phases(config[CONF_CURRENT_PHASES])) cg.add(var.set_pga_gain(config[CONF_GAIN_PGA])) diff --git a/esphome/components/atm90e32/text_sensor/__init__.py b/esphome/components/atm90e32/text_sensor/__init__.py index 30585cb8735..6b959c19fa8 100644 --- a/esphome/components/atm90e32/text_sensor/__init__.py +++ b/esphome/components/atm90e32/text_sensor/__init__.py @@ -36,14 +36,13 @@ 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 phase_cfg := config.get(CONF_PHASE_STATUS): for i, key in enumerate(PHASE_KEYS): if sub_phase_cfg := phase_cfg.get(key): sens = await text_sensor.new_text_sensor(sub_phase_cfg) - cg.add(parent.set_phase_status_text_sensor(i, sens)) + cg.add(hub.set_phase_status_text_sensor(i, sens)) - if freq_status_config := config.get(CONF_FREQUENCY_STATUS): - sens = await text_sensor.new_text_sensor(freq_status_config) - cg.add(parent.set_freq_status_text_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_FREQUENCY_STATUS, hub.set_freq_status_text_sensor) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index b882aaa6b75..1480faa8c3f 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -6,7 +6,7 @@ import esphome.codegen as cg from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, - include_builtin_idf_component, + request_http_client, require_certificate_bundle, ) import esphome.config_validation as cv @@ -334,8 +334,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N async def to_code(config: ConfigType) -> None: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) - include_builtin_idf_component("esp_http_client") + request_http_client() # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index e69f33ac2d5..9f2960620e1 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -72,7 +72,7 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr &ou return ESP_ERR_INVALID_STATE; } -esp_err_t AudioReader::start(AudioFile *audio_file, AudioFileType &file_type) { +esp_err_t AudioReader::start(const AudioFile *audio_file, AudioFileType &file_type) { file_type = AudioFileType::NONE; this->current_audio_file_ = audio_file; diff --git a/esphome/components/audio/audio_reader.h b/esphome/components/audio/audio_reader.h index b1f76172b01..db85185ca6c 100644 --- a/esphome/components/audio/audio_reader.h +++ b/esphome/components/audio/audio_reader.h @@ -47,7 +47,7 @@ class AudioReader { /// @param audio_file AudioFile struct containing the file. /// @param file_type AudioFileType variable passed-by-reference indicating the type of file being read. /// @return ESP_OK - esp_err_t start(AudioFile *audio_file, AudioFileType &file_type); + esp_err_t start(const AudioFile *audio_file, AudioFileType &file_type); /// @brief Reads new file data from the source and sends to the ring buffer sink. /// @return AudioReaderState @@ -69,7 +69,7 @@ class AudioReader { esp_http_client_handle_t client_{nullptr}; - AudioFile *current_audio_file_{nullptr}; + const AudioFile *current_audio_file_{nullptr}; AudioFileType audio_file_type_{AudioFileType::NONE}; const uint8_t *file_current_{nullptr}; }; 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/audio_dac.h b/esphome/components/audio_dac/audio_dac.h index 16a422f4ac7..aebdd85540c 100644 --- a/esphome/components/audio_dac/audio_dac.h +++ b/esphome/components/audio_dac/audio_dac.h @@ -14,6 +14,10 @@ class AudioDac { virtual bool is_muted() = 0; virtual float volume() = 0; + /// Called from the main loop by the speaker driving this DAC once its audio clocks are running. + /// DACs that can only be configured while clocked (e.g. DSP state lost across clock stops) override this. + virtual void on_audio_started() {} + protected: bool is_muted_{false}; }; 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/audio_file/__init__.py b/esphome/components/audio_file/__init__.py index d59ed7411a4..7726ef5837e 100644 --- a/esphome/components/audio_file/__init__.py +++ b/esphome/components/audio_file/__init__.py @@ -19,7 +19,7 @@ from esphome.const import ( CONF_URL, ) from esphome.core import CORE, ID, HexInt -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, ProgmemAssignmentExpression from esphome.external_files import download_web_files_in_config from esphome.types import ConfigType @@ -151,11 +151,14 @@ TYPED_FILE_SCHEMA = cv.typed_schema( ) +CONF_FILE_DATA_ID = "file_data_id" + MEDIA_FILE_TYPE_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.declare_id(audio.AudioFile), cv.Required(CONF_FILE): _file_schema, cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + cv.GenerateID(CONF_FILE_DATA_ID): cv.declare_id(audio.AudioFile), } ) @@ -217,9 +220,9 @@ def audio_files_schema() -> cv.All: def generate_audio_file_code(file_config: ConfigType) -> MockObj: - """Generate the progmem data, AudioFile struct, and Pvariable for one file. + """Generate the progmem data and a flash AudioFile for one file. - Returns the created Pvariable. Caller is responsible for any further + Returns a const pointer to the AudioFile. Caller is responsible for any further registration (the audio_file component additionally registers each file in its named C++ registry; other consumers may skip that). """ @@ -230,17 +233,33 @@ def generate_audio_file_code(file_config: ConfigType) -> MockObj: else: data, media_file_type = read_audio_file_and_type(file_config) - rhs = [HexInt(x) for x in data] - prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs) - - media_files_struct = cg.StructInitializer( + # Global constants so the AudioFile lives in flash; the id stays a plain pointer + # because actions render id arguments as ``::``. + data_id = file_config[CONF_RAW_DATA_ID] + cg.add_global( + ProgmemAssignmentExpression( + data_id.type, data_id, cg.safe_exp([HexInt(x) for x in data]) + ) + ) + media_file = cg.StructInitializer( audio.AudioFile, - ("data", prog_arr), - ("length", len(rhs)), + ("data", MockObj(data_id, ".")), + ("length", len(data)), ("file_type", media_file_type), ) - - return cg.new_Pvariable(file_config[CONF_ID], media_files_struct) + file_var_id = file_config[CONF_ID] + storage = file_config[CONF_FILE_DATA_ID] + cg.add_global( + cg.RawStatement(f"static constexpr {storage.type} {storage} = {media_file};") + ) + cg.add_global( + cg.RawStatement( + f"static const {storage.type} *const {file_var_id} = &{storage};" + ) + ) + var = MockObj(file_var_id, "->") + CORE.register_variable(file_var_id, var) + return var CONFIG_SCHEMA = cv.All( diff --git a/esphome/components/audio_file/audio_file.h b/esphome/components/audio_file/audio_file.h index 537e19fb3ca..177374cf91a 100644 --- a/esphome/components/audio_file/audio_file.h +++ b/esphome/components/audio_file/audio_file.h @@ -10,14 +10,14 @@ namespace esphome::audio_file { struct NamedAudioFile { - audio::AudioFile *file; + const audio::AudioFile *file; const char *file_id; }; inline StaticVector named_audio_files; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -inline void add_named_audio_file(audio::AudioFile *file, const char *file_id) { +inline void add_named_audio_file(const audio::AudioFile *file, const char *file_id) { named_audio_files.push_back({file, file_id}); } diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h index d269f77c357..12735eaca18 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.h +++ b/esphome/components/audio_file/media_source/audio_file_media_source.h @@ -46,7 +46,7 @@ class AudioFileMediaSource final : public Component, protected: std::unique_ptr decoder_; audio::AudioStreamInfo stream_info_; - audio::AudioFile *current_file_{nullptr}; + const audio::AudioFile *current_file_{nullptr}; // Written from the main loop in handle_command(), read from the decoder task in // on_audio_write(). Must be atomic to avoid a data race. diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp index fb8620f7d9a..6cea4d73115 100644 --- a/esphome/components/audio_http/audio_http_media_source.cpp +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -33,6 +33,9 @@ void AudioHTTPMediaSource::dump_config() { " Persistent Ring Buffer: %s\n" " Decoder Task Stack in PSRAM: %s", this->buffer_size_, YESNO(this->persistent_ring_buffer_), YESNO(this->decoder_task_stack_in_psram_)); +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + ESP_LOGCONFIG(TAG, " Custom CA Certificate: %s", YESNO(this->http_ca_certificate_ != nullptr)); +#endif } void AudioHTTPMediaSource::setup() { @@ -52,6 +55,12 @@ void AudioHTTPMediaSource::setup() { config.reader_stack_size = READER_TASK_STACK_SIZE; config.decoder_stack_size = DECODER_TASK_STACK_SIZE; config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_; +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + // micro-decoder verifies HTTPS against this PEM only, skipping the built-in certificate bundle. + if (this->http_ca_certificate_ != nullptr) { + config.http_ca_certificate = this->http_ca_certificate_; + } +#endif this->decoder_ = std::make_unique(config); if (this->decoder_ == nullptr) { diff --git a/esphome/components/audio_http/audio_http_media_source.h b/esphome/components/audio_http/audio_http_media_source.h index a97025e53e8..e75dcf7a678 100644 --- a/esphome/components/audio_http/audio_http_media_source.h +++ b/esphome/components/audio_http/audio_http_media_source.h @@ -34,6 +34,11 @@ class AudioHTTPMediaSource final : public Component, void set_buffer_size(size_t buffer_size) { this->buffer_size_ = buffer_size; } void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; } void set_persistent_ring_buffer(bool persistent) { this->persistent_ring_buffer_ = persistent; } +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + /// PEM-encoded CA certificate(s) used as the sole trust anchor for HTTPS playback URLs, + /// replacing the built-in certificate bundle. + void set_http_ca_certificate(const char *ca_certificate) { this->http_ca_certificate_ = ca_certificate; } +#endif // MediaSource interface implementation bool play_uri(const std::string &uri) override; @@ -56,6 +61,9 @@ class AudioHTTPMediaSource final : public Component, std::atomic pause_{false}; bool decoder_task_stack_in_psram_{false}; bool persistent_ring_buffer_{false}; +#ifdef USE_AUDIO_HTTP_CA_CERTIFICATE + const char *http_ca_certificate_{nullptr}; +#endif }; } // namespace esphome::audio_http diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py index 14543957e95..3c23517b92f 100644 --- a/esphome/components/audio_http/media_source.py +++ b/esphome/components/audio_http/media_source.py @@ -1,13 +1,22 @@ +import logging +from pathlib import Path + import esphome.codegen as cg from esphome.components import audio, media_source, psram import esphome.config_validation as cv from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM +import esphome.final_validate as fv from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@kahrendt"] AUTO_LOAD = ["audio"] CONF_PERSISTENT_RING_BUFFER = "persistent_ring_buffer" +CONF_CA_CERTIFICATE_PATH = "ca_certificate_path" +CONF_HTTP_REQUEST = "http_request" +CONF_VERIFY_SSL = "verify_ssl" audio_http_ns = cg.esphome_ns.namespace("audio_http") AudioHTTPMediaSource = audio_http_ns.class_( @@ -20,6 +29,38 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType: return config +def _inherit_ca_certificate_path(config: ConfigType) -> ConfigType: + # Default to the CA certificate configured on the http_request component so + # HTTPS playback verifies against the same trust anchor without repeating + # the option on every source. Needed because audio_http sources are often + # declared by device packages and cannot be extended from the device config. + # The PEM replaces the built-in certificate bundle as the sole trust anchor, + # so make the inheritance visible in the log. + if CONF_CA_CERTIFICATE_PATH in config: + return config + fconf = fv.full_config.get() + if CONF_HTTP_REQUEST not in fconf: + return config + http_request_config = fconf[CONF_HTTP_REQUEST] + # Mirror http_request's own semantics: it only applies its CA when SSL + # verification is enabled, so neither does the inheritance. + if not http_request_config.get(CONF_VERIFY_SSL, True): + return config + if ca_cert_path := http_request_config.get(CONF_CA_CERTIFICATE_PATH): + _LOGGER.info( + "audio_http source '%s' is inheriting ca_certificate_path from the " + "http_request component; HTTPS playback will verify against that PEM " + "instead of the built-in certificate bundle", + config[CONF_ID].id, + ) + # Already validated by http_request's schema; the value is a resolved Path. + config[CONF_CA_CERTIFICATE_PATH] = ca_cert_path + return config + + +FINAL_VALIDATE_SCHEMA = _inherit_ca_certificate_path + + CONFIG_SCHEMA = cv.All( media_source.media_source_schema( AudioHTTPMediaSource, @@ -31,6 +72,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_PERSISTENT_RING_BUFFER, default=False): cv.boolean, + cv.Optional(CONF_CA_CERTIFICATE_PATH): cv.file_, } ) .extend(cv.COMPONENT_SCHEMA), @@ -47,5 +89,16 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_TASK_STACK_IN_PSRAM): cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) cg.add(var.set_persistent_ring_buffer(config[CONF_PERSISTENT_RING_BUFFER])) + + # Embed the certificate content, like http_request does. Passed to + # micro_decoder's DecoderConfig::http_ca_certificate, which then uses it as + # the sole trust anchor for HTTPS playback URLs instead of the certificate + # bundle. + if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH): + cg.add_define("USE_AUDIO_HTTP_CA_CERTIFICATE") + with Path(ca_cert_path).open(encoding="utf-8") as f: + ca_cert_content = f.read() + cg.add(var.set_http_ca_certificate(ca_cert_content)) diff --git a/esphome/components/bedjet/sensor/__init__.py b/esphome/components/bedjet/sensor/__init__.py index 595e798e490..9319af664f0 100644 --- a/esphome/components/bedjet/sensor/__init__.py +++ b/esphome/components/bedjet/sensor/__init__.py @@ -44,10 +44,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await register_bedjet_child(var, config) - if outlet_temperature_sensor := config.get(CONF_OUTLET_TEMPERATURE): - sensor_var = await sensor.new_sensor(outlet_temperature_sensor) - cg.add(var.set_outlet_temperature_sensor(sensor_var)) - - if ambient_temperature_sensor := config.get(CONF_AMBIENT_TEMPERATURE): - sensor_var = await sensor.new_sensor(ambient_temperature_sensor) - cg.add(var.set_ambient_temperature_sensor(sensor_var)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_OUTLET_TEMPERATURE, var.set_outlet_temperature_sensor) + await sensors(CONF_AMBIENT_TEMPERATURE, var.set_ambient_temperature_sensor) diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index a114ab42051..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 @@ -61,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"] @@ -134,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") @@ -641,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), @@ -648,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) @@ -669,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/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_device_base/automation.h b/esphome/components/ble_device_base/automation.h index ba3128c0eed..a50813c5856 100644 --- a/esphome/components/ble_device_base/automation.h +++ b/esphome/components/ble_device_base/automation.h @@ -11,29 +11,34 @@ #include "esphome/core/automation.h" #include "esphome/core/helpers.h" -#include -#include - namespace esphome::ble_device_base { +/// True if `address` is in `table`, a list of MACs ended by 0. +inline bool mac_in_table(const uint64_t *table, uint64_t address) { + for (; *table != 0; table++) { + if (*table == address) + return true; + } + return false; +} + // on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs. class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: template explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); } - void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } + /// Table of MACs ended by 0; must outlive the trigger. + void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; } bool parse_device(const ESPBTDevice &device) override { - if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(), - device.address_uint64()) == this->addresses_.end()) { + if (this->addresses_ != nullptr && !mac_in_table(this->addresses_, device.address_uint64())) return false; - } this->trigger(device); return true; } protected: - FixedVector addresses_; + const uint64_t *addresses_{nullptr}; }; // on_ble_service_data_advertise: fires when an advertisement contains service diff --git a/esphome/components/ble_device_base/automation.py b/esphome/components/ble_device_base/automation.py index 6acc4edb924..7cc87add5a0 100644 --- a/esphome/components/ble_device_base/automation.py +++ b/esphome/components/ble_device_base/automation.py @@ -69,13 +69,24 @@ def uuid_trigger_schema( ) +def _filter_mac(value: Any) -> Any: + mac = cv.mac_address(value) + if not any(mac.parts): + # 0 ends the flash MAC table, and it is never a real device address. + raise cv.Invalid("00:00:00:00:00:00 cannot be used as a MAC address filter") + return mac + + +MAC_FILTER_LIST = cv.ensure_list(_filter_mac) + + def advertise_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any]: """on_ble_advertise schema: multi-mac list filter, unlike the single-mac uuid_trigger_schema() — pairs with advertise_trigger_to_code().""" return automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), - cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST, } ) @@ -94,11 +105,18 @@ def scan_end_trigger_schema(trigger_class: MockObjClass) -> Callable[[Any], Any] _count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE) +def mac_filter_table(macs: list) -> cg.MockObj: + """Shared flash table of MACs ended by 0 (never a valid address), so triggers store a pointer.""" + return cg.shared_progmem_array( + "ble_mac_filter", cg.uint64, [*(mac.as_hex for mac in macs), 0] + ) + + async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None: """Build an on_ble_advertise trigger (optional multi-mac filter).""" trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if (macs := conf.get(CONF_MAC_ADDRESS)) is not None: - cg.add(trigger.set_addresses([it.as_hex for it in macs])) + if macs := conf.get(CONF_MAC_ADDRESS): + cg.add(trigger.set_addresses(mac_filter_table(macs))) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) _count_listener() 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 986a67c7a8f..d3af02780fe 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -17,6 +17,13 @@ #include "esphome/core/log.h" #include +#include + +// cancel_open exists from ESP-IDF 5.5.5 and 6.0.1. +#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 5) && ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0)) || \ + ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 1) +#define BLUEDROID_HAS_CANCEL_OPEN +#endif namespace esphome::bluetooth_connection { @@ -63,6 +70,12 @@ void BluedroidGattClient::loop() { // The one teardown safety net: a lost CLOSE_EVT, or a scheduled // teardown whose OPEN_EVT never arrives. if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { + if (this->conn_id_ == UNSET_CONN_ID) { + this->cancel_pending_open_(); + } else if (st == ClientState::CONNECTING) { + // CONNECT_EVT came but OPEN_EVT was lost, so our open still holds the ACL link. + this->check_and_log_error_("esp_ble_gap_disconnect", esp_ble_gap_disconnect(this->remote_bda_)); + } ESP_LOGE(TAG, "[%d] Timeout waiting for teardown, forcing IDLE", this->connection_index_); // Release before idling: a lost completion must not leak the cache. this->release_services(); @@ -140,6 +153,8 @@ void BluedroidGattClient::tracker_connect_() { this->services_released_ = false; this->seen_mtu_ = false; this->mtu_failed_ = false; + this->cancel_open_sent_ = false; + this->conn_id_ = UNSET_CONN_ID; this->enable_loop(); this->set_state(ClientState::CONNECTING); if (this->connection_type_ == ConnectionType::V3_WITHOUT_CACHE) { @@ -183,12 +198,28 @@ int BluedroidGattClient::gatt_disconnect() { // Arm the safety window: a lost OPEN_EVT must not leak the teardown. this->disconnecting_started_ = millis(); this->enable_loop(); + if (this->conn_id_ == UNSET_CONN_ID) { + // CANCEL_OPEN_EVT or a racing OPEN_EVT settles the slot. + this->cancel_pending_open_(); + } return 0; } this->unconditional_disconnect_(); return 0; } +void BluedroidGattClient::cancel_pending_open_() { +#ifdef BLUEDROID_HAS_CANCEL_OPEN + esp_ble_gattc_cancel_open_params_t params{}; + params.gattc_if = this->gattc_if_; + memcpy(params.remote_bda, this->remote_bda_, sizeof(esp_bd_addr_t)); + // A refused cancel schedules no CANCEL_OPEN_EVT, so the teardown stays cancellable. + if (this->check_and_log_error_("esp_ble_gattc_cancel_open", esp_ble_gattc_cancel_open(¶ms)) == ESP_OK) { + this->cancel_open_sent_ = true; + } +#endif +} + void BluedroidGattClient::unconditional_disconnect_() { ESP_LOGI(TAG, "[%d] Disconnecting (conn_id: %d)", this->connection_index_, this->conn_id_); if (this->conn_id_ == UNSET_CONN_ID) { @@ -214,6 +245,10 @@ bool BluedroidGattClient::cancel_gatt_disconnect() { if (this->state() != ClientState::CONNECTING || !this->disconnect_pending()) { return false; } + if (this->cancel_open_sent_) { + // The cancel already went out, so CANCEL_OPEN_EVT ends it. + return false; + } this->want_disconnect_ = false; return true; } @@ -574,6 +609,8 @@ void BluedroidGattClient::handle_open_evt_(esp_ble_gattc_cb_param_t *param) { this->listener_->on_connection_state(false, 0, param->open.status); return; } + // ALREADY_OPEN on an existing link sends no CONNECT_EVT, so take the id here. + this->conn_id_ = param->open.conn_id; if (this->disconnect_pending()) { // Open resolved with a teardown scheduled: close now (conn_id_ stays set // so CLOSE_EVT still matches). @@ -617,6 +654,11 @@ void BluedroidGattClient::handle_disconnect_evt_(esp_ble_gattc_cb_param_t *param // Active close delivers CLOSE_EVT first; never walk back to DISCONNECTING. return; } + if (this->conn_id_ == UNSET_CONN_ID) { + // No CONNECT_EVT for this attempt yet, so the event belongs to an earlier + // link, such as one the teardown timeout forced down. + return; + } // Passive disconnect: wait for CLOSE_EVT before going IDLE (reconnecting // earlier makes the controller reject with 133 or assert) and before // reporting - the wrapper frees the slot on the report, and a freed slot @@ -645,6 +687,10 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga case ESP_GATTC_CONNECT_EVT: { if (!this->check_addr_(param->connect.remote_bda)) return false; + // Every client interface gets CONNECT_EVT for every new link; only an + // attempt in flight owns it. An idle slot must not adopt another slot's link. + if (this->state() != ClientState::CONNECTING) + break; this->conn_id_ = param->connect.conn_id; // MTU request here rather than OPEN_EVT, matching the IDF examples. auto ret = esp_ble_gattc_send_mtu_req(this->gattc_if_, param->connect.conn_id); @@ -656,8 +702,15 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga break; } case ESP_GATTC_OPEN_EVT: { - if (!this->check_addr_(param->open.remote_bda)) + if (!this->check_addr_(param->open.remote_bda)) { + // Our interface (filtered above), stale address, so nobody tracks this link. + if (param->open.status == ESP_GATT_OK || param->open.status == ESP_GATT_ALREADY_OPEN) { + ESP_LOGW(TAG, "[%d] Closing link left by an abandoned open", this->connection_index_); + this->check_and_log_error_("esp_ble_gattc_close", esp_ble_gattc_close(this->gattc_if_, param->open.conn_id)); + return true; + } return false; + } this->handle_open_evt_(param); break; } @@ -748,6 +801,24 @@ bool BluedroidGattClient::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga this->listener_->on_notify_data(param->notify.handle, param->notify.value, param->notify.value_len); break; } +#ifdef BLUEDROID_HAS_CANCEL_OPEN + case ESP_GATTC_CANCEL_OPEN_EVT: { + if (!this->check_addr_(param->cancel_open.remote_bda)) + return false; + if (param->cancel_open.status != ESP_GATT_OK) { + // Too late to cancel: OPEN_EVT follows and settles the slot. The latch + // stays set because the event cannot be tied to this attempt's cancel. + break; + } + if (this->state() == ClientState::CONNECTING && this->disconnect_pending()) { + ESP_LOGD(TAG, "[%d] Pending open cancelled", this->connection_index_); + this->release_services(); + this->set_idle_(); + this->listener_->on_connection_state(false, 0, ESP_GATT_CONN_CONN_CANCEL); + } + break; + } +#endif default: break; } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index a4e9edec233..ef0ca223cb5 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -100,6 +100,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public int handle_search_cmpl_(esp_gatt_status_t status); void deliver_pending_search_(); void unconditional_disconnect_(); + void cancel_pending_open_(); void set_idle_(); void set_disconnecting_(); esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, @@ -133,6 +134,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public bool seen_mtu_ : 1 {false}; // The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead. bool mtu_failed_ : 1 {false}; + // esp_ble_gattc_cancel_open() was accepted; CANCEL_OPEN_EVT or OPEN_EVT ends + // the attempt, so the scheduled teardown can no longer be cancelled. + bool cancel_open_sent_ : 1 {false}; // Search issued at OPEN_EVT overlaps the MTU exchange; discover_services() // completes from it. Reset by set_idle_(). static_assert(static_cast(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow"); 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/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/bmi160/sensor.py b/esphome/components/bmi160/sensor.py index 4309f0a79f1..9ad1b19fa0e 100644 --- a/esphome/components/bmi160/sensor.py +++ b/esphome/components/bmi160/sensor.py @@ -88,16 +88,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - for d in ["x", "y", "z"]: - accel_key = f"acceleration_{d}" - if accel_key in config: - sens = await sensor.new_sensor(config[accel_key]) - cg.add(getattr(var, f"set_accel_{d}_sensor")(sens)) - accel_key = f"gyroscope_{d}" - if accel_key in config: - sens = await sensor.new_sensor(config[accel_key]) - cg.add(getattr(var, f"set_gyro_{d}_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_ACCELERATION_X, var.set_accel_x_sensor) + await sensors(CONF_GYROSCOPE_X, var.set_gyro_x_sensor) + await sensors(CONF_ACCELERATION_Y, var.set_accel_y_sensor) + await sensors(CONF_GYROSCOPE_Y, var.set_gyro_y_sensor) + await sensors(CONF_ACCELERATION_Z, var.set_accel_z_sensor) + await sensors(CONF_GYROSCOPE_Z, var.set_gyro_z_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/bmp085/sensor.py b/esphome/components/bmp085/sensor.py index e4e559844e1..5eb8dae7314 100644 --- a/esphome/components/bmp085/sensor.py +++ b/esphome/components/bmp085/sensor.py @@ -48,10 +48,6 @@ 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(sens)) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_PRESSURE, var.set_pressure) diff --git a/esphome/components/bmp280_base/__init__.py b/esphome/components/bmp280_base/__init__.py index c0f0ae90bf9..a956ed34f6e 100644 --- a/esphome/components/bmp280_base/__init__.py +++ b/esphome/components/bmp280_base/__init__.py @@ -75,14 +75,13 @@ 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)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + temperature_config = config[CONF_TEMPERATURE] 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)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + pressure_config = config[CONF_PRESSURE] cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING])) cg.add(var.set_iir_filter(config[CONF_IIR_FILTER])) diff --git a/esphome/components/bmp3xx_base/__init__.py b/esphome/components/bmp3xx_base/__init__.py index 75e168378e1..6d0618e136a 100644 --- a/esphome/components/bmp3xx_base/__init__.py +++ b/esphome/components/bmp3xx_base/__init__.py @@ -80,18 +80,17 @@ async def to_code_base(config: ConfigType) -> MockObj: await cg.register_component(var, config) cg.add(var.set_iir_filter_config(config[CONF_IIR_FILTER])) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + temperature_config = config[CONF_TEMPERATURE] cg.add( var.set_temperature_oversampling_config( 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)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + pressure_config = config[CONF_PRESSURE] cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING])) return var diff --git a/esphome/components/bmp581_base/__init__.py b/esphome/components/bmp581_base/__init__.py index 1c2c5c37d42..5d031a8208a 100644 --- a/esphome/components/bmp581_base/__init__.py +++ b/esphome/components/bmp581_base/__init__.py @@ -137,9 +137,9 @@ CONFIG_SCHEMA_BASE = cv.Schema( 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)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + temperature_config = config[CONF_TEMPERATURE] cg.add( var.set_temperature_oversampling_config( temperature_config[CONF_OVERSAMPLING] @@ -149,9 +149,8 @@ async def to_code_base(config: ConfigType) -> MockObj: var.set_temperature_iir_filter_config(temperature_config[CONF_IIR_FILTER]) ) - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + pressure_config = config[CONF_PRESSURE] cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING])) cg.add(var.set_pressure_iir_filter_config(pressure_config[CONF_IIR_FILTER])) diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 1ebabea0a33..51f5d866a49 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -422,6 +422,21 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat } break; } + case 0x45: { // temperature (0.1C), e.g. Shelly BLU H&T + if (this->temperature_ != nullptr) { + const int16_t raw = encode_uint16(value[1], value[0]); + this->temperature_->publish_state(raw * 0.1f); + reported = true; + } + break; + } + case 0x2E: { // humidity (uint8, 1%), e.g. Shelly BLU H&T + if (this->humidity_ != nullptr) { + this->humidity_->publish_state(value[0]); + reported = true; + } + break; + } default: break; } 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..65d5d6d9f1d 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_VALUE, CONF_WAIT_TIME, ) -from esphome.core import ID CODEOWNERS = ["@lygris", "@gabest11"] DEPENDENCIES = ["spi"] @@ -351,10 +350,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 +359,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): @@ -395,7 +383,10 @@ def validate_raw_data(value): SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(CC1101Component), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + # TX FIFO size + cv.Required(CONF_DATA): cv.templatable( + cv.All(validate_raw_data, cv.Length(max=64)) + ), }, key=CONF_DATA, ) @@ -410,105 +401,51 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( async def send_packet_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]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], + args, + var.set_data_template, + var.set_data_static, + "cc1101_data", + ) 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.cpp b/esphome/components/cc1101/cc1101.cpp index f7b90b91cf6..91a29d8d533 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -3,10 +3,16 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include + +#ifdef USE_ESP32 +#include +#endif namespace esphome::cc1101 { static const char *const TAG = "cc1101"; +static constexpr size_t TX_FIFO_SIZE = 64; static void split_float(float value, int mbits, uint8_t &e, uint32_t &m) { int e_tmp; @@ -379,18 +385,32 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) { this->disable(); } -CC1101Error CC1101Component::transmit_packet(const std::vector &packet) { +CC1101Error CC1101Component::transmit_packet(const uint8_t *data, size_t len) { if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { return CC1101Error::PARAMS; } + const bool variable = this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE); + // In variable length mode the FIFO also holds the length byte + if (len + (variable ? 1 : 0) > TX_FIFO_SIZE) { + ESP_LOGE(TAG, "Packet of %u bytes does not fit the TX FIFO", static_cast(len)); + return CC1101Error::PARAMS; + } // Write packet this->enter_idle_(); this->strobe_(Command::FTX); - if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { - this->write_(Register::FIFO, static_cast(packet.size())); + if (variable) { + this->write_(Register::FIFO, static_cast(len)); } - this->write_(Register::FIFO, packet.data(), packet.size()); +#ifdef USE_ESP32 + // SPI DMA cannot read flash or unaligned buffers; stage them here so the driver does not allocate a copy + alignas(4) uint8_t staged[TX_FIFO_SIZE]; + if (len != 0 && (!esp_ptr_dma_capable(data) || (reinterpret_cast(data) & 3) != 0)) { + std::memcpy(staged, data, len); + data = staged; + } +#endif + this->write_(Register::FIFO, data, len); // Calibrate PLL if (!this->enter_calibrated_(State::FSTXON, Command::FSTXON)) { diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 79bfc9cb33e..d7065c60487 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -89,7 +89,10 @@ class CC1101Component final : public Component, void set_whitening(bool value); // Packet mode operations - CC1101Error transmit_packet(const std::vector &packet); + CC1101Error transmit_packet(const std::vector &packet) { + return this->transmit_packet(packet.data(), packet.size()); + } + CC1101Error transmit_packet(const uint8_t *data, size_t len); void register_listener(CC1101Listener *listener) { this->listeners_.push_back(listener); } Trigger, float, float, uint8_t> *get_packet_trigger() { return &this->packet_trigger_; } @@ -130,127 +133,12 @@ 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; } - void set_data_static(const uint8_t *data, size_t len) { - this->data_static_ = data; - this->data_static_len_ = len; - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->data_func_) { - auto data = this->data_func_(x...); - this->parent_->transmit_packet(data); - } else if (this->data_static_ != nullptr) { - std::vector data(this->data_static_, this->data_static_ + this->data_static_len_); - this->parent_->transmit_packet(data); - } + this->data_.visit([this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); } - - protected: - std::function(Ts...)> data_func_{}; - const uint8_t *data_static_{nullptr}; - 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/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..d08952115bd 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -368,19 +368,11 @@ optional Climate::restore_state_() { } void Climate::save_state_(const ClimateTraits &traits) { -#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wclass-memaccess" -#define TEMP_IGNORE_MEMACCESS -#endif ClimateDeviceRestoreState state{}; - // initialize as zero (including padding) to prevent random data on stack triggering erase + // initialize as zero (including padding) to prevent random data on stack triggering erase; + // the void * cast tells GCC the bytewise clear of a non-trivial type is intentional // NOLINTNEXTLINE(bugprone-raw-memory-call-on-non-trivial-type) -- intentional bytewise zero for RTC save - memset(&state, 0, sizeof(ClimateDeviceRestoreState)); -#ifdef TEMP_IGNORE_MEMACCESS -#pragma GCC diagnostic pop -#undef TEMP_IGNORE_MEMACCESS -#endif + memset(static_cast(&state), 0, sizeof(ClimateDeviceRestoreState)); state.mode = this->mode; if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | @@ -435,6 +427,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..c3939735a43 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( @@ -60,9 +54,8 @@ 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 co2_config := config.get(CONF_CO2): - sens = await sensor.new_sensor(co2_config) - cg.add(var.set_co2_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( @@ -72,18 +65,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/combination/sensor.py b/esphome/components/combination/sensor.py index ccc5a03964a..34e9b99ce84 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -203,6 +203,5 @@ async def to_code(config: ConfigType) -> None: else: cg.add(var.add_source(source)) - if CONF_STD_DEV in config: - sens = await sensor.new_sensor(config[CONF_STD_DEV]) - cg.add(var.set_std_dev_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_STD_DEV, var.set_std_dev_sensor) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 256ab5c0a3c..6e98162baae 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -7,6 +7,7 @@ BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" +CONF_ALLOWED_IPS = "allowed_ips" CONF_B_CONSTANT = "b_constant" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" @@ -15,6 +16,8 @@ CONF_CLIMATE_ID = "climate_id" CONF_CO2_EQUIVALENT = "co2_equivalent" CONF_COLOR_DEPTH = "color_depth" CONF_COLUMNS = "columns" +CONF_CONNECTED = "connected" +CONF_CONVERSION_RATE = "conversion_rate" CONF_CRC_ENABLE = "crc_enable" CONF_DATA_BITS = "data_bits" CONF_DESCRIPTION = "description" @@ -23,6 +26,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" @@ -38,7 +43,9 @@ CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" +CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_REQUEST_HEADERS = "request_headers" +CONF_ROLE = "role" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" @@ -61,3 +68,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/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..d087c99bf1a 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" @@ -120,32 +117,18 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_hpf_enable(config[CONF_CURRENT_HPF], config[CONF_VOLTAGE_HPF])) cg.add(var.set_pulse_energy_wh(config[CONF_PULSE_ENERGY])) - 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)) + 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) -@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/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/sensor.py b/esphome/components/debug/sensor.py index 80d1daa81f1..07c67a2b937 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -106,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 2e02af67cbe..43ed0d15c62 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -31,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_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 0bbca4c5c4d..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 @@ -298,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/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/dht/sensor.py b/esphome/components/dht/sensor.py index 7376adb2874..c7ae70b8344 100644 --- a/esphome/components/dht/sensor.py +++ b/esphome/components/dht/sensor.py @@ -61,11 +61,8 @@ async def to_code(config: ConfigType) -> None: pin = await gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - 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_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) cg.add(var.set_dht_model(config[CONF_MODEL])) diff --git a/esphome/components/dht12/sensor.py b/esphome/components/dht12/sensor.py index 2bc6e945153..88877b2dc26 100644 --- a/esphome/components/dht12/sensor.py +++ b/esphome/components/dht12/sensor.py @@ -46,10 +46,6 @@ 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_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_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_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.h b/esphome/components/display/display.h index c1389721496..cc75eb70b8f 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -287,13 +287,15 @@ template class DisplayWriter { // Type alias for Display writer - uses optimized DisplayWriter instead of std::function using display_writer_t = DisplayWriter; +// Callers usually pass `this`; checking a copy keeps GCC's -Wnonnull-compare quiet. #define LOG_DISPLAY(prefix, type, obj) \ - if ((obj) != nullptr) { \ + if (auto *log_display_obj = (obj); log_display_obj != nullptr) { \ ESP_LOGCONFIG(TAG, \ prefix type "\n" \ "%s Rotations: %d °\n" \ "%s Dimensions: %dpx x %dpx", \ - prefix, (obj)->rotation_, prefix, (obj)->get_width(), (obj)->get_height()); \ + prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \ + log_display_obj->get_height()); \ } /// Turn the pixel OFF. @@ -848,36 +850,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_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/dps310/sensor.py b/esphome/components/dps310/sensor.py index 8b8fd8373ba..e3827b484c4 100644 --- a/esphome/components/dps310/sensor.py +++ b/esphome/components/dps310/sensor.py @@ -54,10 +54,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature_sensor(sens)) - - if pressure := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure) - cg.add(var.set_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) 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..6b035d5c982 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( @@ -79,83 +63,31 @@ async def to_code(config: ConfigType) -> None: if CONF_LAMBDA in config: lambda_ = await cg.process_lambda(config[CONF_LAMBDA], [], return_type=cg.bool_) cg.add(var.set_lambda(lambda_)) - if CONF_LAST_TIME in config: - sens = await sensor.new_sensor(config[CONF_LAST_TIME]) - cg.add(var.set_last_duty_time_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_LAST_TIME, var.set_last_duty_time_sensor) # 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/ee895/sensor.py b/esphome/components/ee895/sensor.py index fdad47fb05c..a7110bd6d2e 100644 --- a/esphome/components/ee895/sensor.py +++ b/esphome/components/ee895/sensor.py @@ -57,14 +57,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature_sensor(sens)) - - if co2 := config.get(CONF_CO2): - sens = await sensor.new_sensor(co2) - cg.add(var.set_co2_sensor(sens)) - - if pressure := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure) - cg.add(var.set_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) 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/emontx/sensor/__init__.py b/esphome/components/emontx/sensor/__init__.py index 56a7fb8b559..eadba1ea3f8 100644 --- a/esphome/components/emontx/sensor/__init__.py +++ b/esphome/components/emontx/sensor/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, @@ -70,7 +71,7 @@ SENSOR_CONFIGS = { "E": { CONF_UNIT_OF_MEASUREMENT: UNIT_WATT_HOURS, CONF_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - CONF_STATE_CLASS: STATE_CLASS_TOTAL_INCREASING, + CONF_STATE_CLASS: STATE_CLASS_TOTAL, CONF_ACCURACY_DECIMALS: 0, }, "V": { 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/ens210/sensor.py b/esphome/components/ens210/sensor.py index bfd758f92f3..cb49986f7c5 100644 --- a/esphome/components/ens210/sensor.py +++ b/esphome/components/ens210/sensor.py @@ -50,10 +50,6 @@ 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_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_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) 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..487e8e5ef40 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,18 @@ 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()"), +) + +automation.register_apply_condition( + "epaper_spi.is_updating", + automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(EPaperBase)}), + "is_updating()", +) + # 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 +84,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 +127,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 +165,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 3b3418d911f..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; @@ -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..96755a404ec 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,10 @@ 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; } + // True from the start of an update until the display has refreshed and gone back to sleep. + bool is_updating() const { return this->state_ != EPaperState::IDLE; } protected: int get_height_internal() override { return this->height_; }; @@ -185,6 +182,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..eb51ba1f2af 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): @@ -25,3 +26,15 @@ goodisplay_gdey042t81 = ssd1683.extend( width=400, height=300, ) + +# The SSD1681 shares the SSD1683 command set for everything this driver uses: +# the same 0x01/0x11/0x18 initialisation, the same 0x44/0x45 window and +# 0x4E/0x4F cursor registers with a byte-addressed X and a 16-bit Y, and the +# same 0x22/0x20 update. It only differs in the panel sizes it drives. +ssd1681 = SSD1683("ssd1681") + +waveshare_1_54in_v2 = ssd1681.extend( + "waveshare-1.54in-v2", + width=200, + height=200, +) 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/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index 0386d84200f..9a4da3744a1 100644 --- a/esphome/components/es8311/es8311.cpp +++ b/esphome/components/es8311/es8311.cpp @@ -92,6 +92,21 @@ uint8_t ES8311::calculate_resolution_value(ES8311Resolution resolution) { } } +/// Encodes the MCLK pre-multiplier for REG02 bits 3-4. The coefficient table stores the multiplier as the factor +/// itself (1, 2, 4 or 8), while the register takes 0, 1, 2 or 3 (ES8311 datasheet, register 0x02 MULT_PRE). +static uint8_t encode_pre_mult(uint8_t pre_mult) { + switch (pre_mult) { + case 2: + return 1; + case 4: + return 2; + case 8: + return 3; + default: + return 0; + } +} + const ES8311Coefficient *ES8311::get_coefficient(uint32_t mclk, uint32_t rate) { for (const auto &coefficient : ES8311_COEFFICIENTS) { if (coefficient.mclk == mclk && coefficient.rate == rate) @@ -127,7 +142,7 @@ bool ES8311::configure_clock_() { ES8311_ERROR_CHECK(this->read_byte(ES8311_REG02_CLK_MANAGER, ®02)); reg02 &= 0x07; reg02 |= (coefficient->pre_div - 1) << 5; - reg02 |= coefficient->pre_mult << 3; + reg02 |= encode_pre_mult(coefficient->pre_mult) << 3; ES8311_ERROR_CHECK(this->write_byte(ES8311_REG02_CLK_MANAGER, reg02)); // Register 0x03 diff --git a/esphome/components/es8388/select/__init__.py b/esphome/components/es8388/select/__init__.py index b81bcd13cfb..62ddd6cb38a 100644 --- a/esphome/components/es8388/select/__init__.py +++ b/esphome/components/es8388/select/__init__.py @@ -30,19 +30,13 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: - parent = await cg.get_variable(config[CONF_ES8388_ID]) - if dac_output_config := config.get(CONF_DAC_OUTPUT): - s = await select.new_select( - dac_output_config, - options=["LINE1", "LINE2", "BOTH"], - ) - await cg.register_parented(s, parent) - cg.add(parent.set_dac_output_select(s)) - - if adc_input_mic_config := config.get(CONF_ADC_INPUT_MIC): - s = await select.new_select( - adc_input_mic_config, - options=["LINE1", "LINE2", "DIFFERENCE"], - ) - await cg.register_parented(s, parent) - cg.add(parent.set_adc_input_mic_select(s)) + hub = await cg.get_variable(config[CONF_ES8388_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_DAC_OUTPUT, hub.set_dac_output_select, options=["LINE1", "LINE2", "BOTH"] + ) + await selects( + CONF_ADC_INPUT_MIC, + hub.set_adc_input_mic_select, + options=["LINE1", "LINE2", "DIFFERENCE"], + ) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 748be9ae4cf..df72c590a33 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1,6 +1,6 @@ from collections.abc import Callable, Iterable import contextlib -from dataclasses import dataclass +from dataclasses import dataclass, field import itertools import logging import os @@ -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 ( @@ -54,7 +57,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError, HexInt from esphome.core.config import BOARD_MAX_LENGTH from esphome.coroutine import CoroPriority, coroutine_with_priority -from esphome.espidf.component import generate_idf_components +from esphome.espidf.component import IDFComponent, generate_idf_components import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -73,6 +76,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_MBEDTLS_SDKCONFIG, KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, @@ -111,6 +115,7 @@ 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" @@ -233,7 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers - "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf + "esp-tls", # TLS wrapper - re-included by request_tls() "esp_adc", # ADC driver - only needed by adc component "esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back @@ -262,7 +267,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 @@ -318,6 +323,34 @@ ARDUINO_EXCLUDED_IDF_COMPONENTS = ( "joltwallet__littlefs", # LittleFS - ESPHome doesn't use filesystem ) +# Entries arduino-esp32 only declares below the given IDF version; stubbing one past +# it clashes with ESPHome's own managed component of the same short name. +ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF: dict[str, cv.Version] = { + "espressif__libsodium": cv.Version(6, 0, 0), +} + + +def arduino_bundles_libsodium() -> bool: + """arduino-esp32 ships its own libsodium below IDF 6.0.""" + return ( + CORE.using_arduino + and idf_version() + < ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF["espressif__libsodium"] + ) + + +def arduino_excluded_idf_components() -> set[str]: + """The arduino-bundled components to stub for this build's IDF version.""" + version = idf_version() + return { + component + for component in ARDUINO_EXCLUDED_IDF_COMPONENTS + if (max_version := ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF.get(component)) + is None + or version < max_version + } + + # Mapping of Arduino libraries to IDF managed components they require # When an Arduino library is enabled via cg.add_library(), these components # are automatically un-stubbed from ARDUINO_EXCLUDED_IDF_COMPONENTS. @@ -424,6 +457,7 @@ ARDUINO_DISABLED_LIBRARIES: frozenset[str] = frozenset( "Hash", "HTTPClient", "HTTPUpdate", + "HTTPUpdateServer", "Insights", "LittleFS", "Matter", @@ -637,20 +671,29 @@ def get_download_types(storage_json): # No recorded firmware path means nothing was built; no downloads. if storage_json.firmware_bin_path is None: return [] - return [ - { - "title": "Factory format (Previously Modern)", - "description": "For use with ESPHome Web and other tools.", - "file": "firmware.factory.bin", - "download": f"{storage_json.name}.factory.bin", - }, + from esphome.espidf.toolchain import tree_skips_bootloader + + types = [] + # A --skip-bootloader tree deliberately has no factory image; an + # unreadable tree (PlatformIO, capability probes) reads as full. + if not tree_skips_bootloader(Path(storage_json.firmware_bin_path).parent): + types.append( + { + "title": "Factory format (Previously Modern)", + "description": "For use with ESPHome Web and other tools.", + "file": "firmware.factory.bin", + "download": f"{storage_json.name}.factory.bin", + } + ) + types.append( { "title": "OTA format (Previously Legacy)", "description": "For OTA updating a device.", "file": "firmware.ota.bin", "download": f"{storage_json.name}.ota.bin", - }, - ] + } + ) + return types def only_on_variant(*, supported=None, unsupported=None, msg_prefix="This feature"): @@ -700,9 +743,10 @@ def is_idf_sdkconfig_option_enabled(name: str) -> bool: def set_idf_sdkconfig_default(name: str, value: SdkconfigValueType) -> None: """Set an sdkconfig option unless it is already set. - For the FINAL priority reconcile jobs: they run after every to_code, - including the user's sdkconfig_options, and must not override an - existing value. + User sdkconfig_options take precedence regardless of to_code order: + esp32.to_code applies them unconditionally, and this helper preserves + values that are already set. FINAL priority reconcile jobs use the same + guard because they run after every to_code, including the user's options. """ if name not in CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]: add_idf_sdkconfig_option(name, value) @@ -769,6 +813,97 @@ def request_software_coexistence() -> None: include_builtin_idf_component("esp_coex") +@dataclass +class MbedtlsSdkconfigData: + """Inputs for the mbedTLS sdkconfig flags, reconciled at FINAL. + + Components call the require_mbedtls_*() helpers (and request_tls(), which + sets tls_required and also un-excludes the esp-tls component) rather than + writing the CONFIG_MBEDTLS_* flags directly; _reconcile_mbedtls_sdkconfig() + decides the final values once every to_code has run. + """ + + ecp_required: bool = False # ECDH/ECDSA without TLS (openthread SRP host key) + tls_required: bool = False # mbedTLS TLS role needed without the esp-tls wrapper + tls_server_required: bool = False # server-side TLS/DTLS handshake + tls_extras_required: set[str] = field(default_factory=set) # kept TLS extras + peer_cert_required: bool = False # keep the peer certificate after the handshake + pkcs7_required: bool = False # PKCS#7 parsing + sha512_required: bool = False # SHA-384/SHA-512 + # esp32 advanced disable_mbedtls_* options + disable_tls: bool = True + disable_tls_server: bool = True + disable_tls_extras: bool = True + disable_peer_cert: bool = True + disable_pkcs7: bool = True + + +def _mbedtls_sdkconfig() -> MbedtlsSdkconfigData: + data = CORE.data[KEY_ESP32] + if KEY_MBEDTLS_SDKCONFIG not in data: + data[KEY_MBEDTLS_SDKCONFIG] = MbedtlsSdkconfigData() + return data[KEY_MBEDTLS_SDKCONFIG] + + +# IDF components that reference esp_tls symbols from their own code, so +# re-including any of them is an implicit TLS request. +_ESP_TLS_LINKING_COMPONENTS = ( + "esp-tls", + "esp_http_client", + "esp_https_ota", + "esp_https_server", + "esp_local_ctrl", + "mqtt", +) + + +def _mbedtls_tls_required() -> bool: + """TLS stays in the build: requested, or an esp_tls-linking component was re-included. + + The exclusion-set signal keeps external components working whose only + obligation before request_tls() existed was include_builtin_idf_component() + of esp-tls or of a component that links it (esp_http_client, IDF mqtt). + """ + if _mbedtls_sdkconfig().tls_required: + return True + excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS] + return any(name not in excluded for name in _ESP_TLS_LINKING_COMPONENTS) + + +def _mbedtls_tls_compiled_out() -> bool: + """True when this build removes the TLS stack from mbedTLS entirely.""" + return ( + not CORE.using_arduino + and _mbedtls_sdkconfig().disable_tls + and not _mbedtls_tls_required() + ) + + +def require_mbedtls_tls() -> None: + """Keep the mbedTLS TLS stack without compiling the esp-tls wrapper. + + For code that talks to mbedTLS directly (wpa_supplicant's EAP client). + Components that use esp_tls call request_tls() instead. + """ + _mbedtls_sdkconfig().tls_required = True + + +def request_tls() -> None: + """Request the mbedTLS TLS stack and the esp-tls wrapper. + + Without a request TLS and its ECP/PEM-write/CRL/CSR crypto compile out; + hashes, AES and RSA stay available. + """ + require_mbedtls_tls() + include_builtin_idf_component("esp-tls") + + +def request_http_client() -> None: + """Request ESP-IDF's HTTP client; it links esp_tls even for plain http.""" + include_builtin_idf_component("esp_http_client") + request_tls() + + def add_idf_component( *, name: str, @@ -931,14 +1066,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), @@ -963,6 +1099,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), @@ -998,7 +1135,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), @@ -1019,8 +1156,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", } @@ -1592,6 +1729,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) @@ -1779,6 +1939,7 @@ CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" 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_TLS = "disable_mbedtls_tls" 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" @@ -1794,12 +1955,7 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" # Feature requirement tracking - components can call require_* functions to re-enable # These are stored in CORE.data[KEY_ESP32] dict 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" KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required" @@ -1838,6 +1994,9 @@ def require_certificate_bundle() -> None: certificates (http_request, audio streaming) call this so the bundle is compiled and gen_crt_bundle runs only when something uses it. """ + # esp_crt_bundle.c lives in the mbedtls component and calls + # mbedtls_ssl_conf_*, so a bundle needs the TLS role but not esp-tls. + require_mbedtls_tls() CORE.data[KEY_ESP32][KEY_CERT_BUNDLE] = True @@ -1863,33 +2022,37 @@ def require_usb_serial_jtag_secondary() -> None: CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True -def require_mbedtls_peer_cert() -> None: - """Mark that mbedTLS peer certificate retention is required by a component. +def require_mbedtls_ecp() -> None: + """Keep mbedTLS elliptic curve support (ECDH/ECDSA) without requesting TLS. - Call this from components that need access to the peer certificate after - the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE - from being disabled. + Call this from components that sign or verify with ECDSA outside a TLS + handshake (openthread's SRP host key). WiFi, Bluetooth and secure boot + select it through Kconfig on their own. """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True + _mbedtls_sdkconfig().ecp_required = True + + +def require_mbedtls_peer_cert() -> None: + """Keep the peer certificate after the TLS handshake (CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE). + + A user sdkconfig_options value takes precedence. + """ + _mbedtls_sdkconfig().peer_cert_required = True def require_mbedtls_pkcs7() -> None: - """Mark that mbedTLS PKCS#7 support is required by a component. - - Call this from components that need PKCS#7 certificate validation. - This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled. - """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True + """Keep mbedTLS PKCS#7 support (CONFIG_MBEDTLS_PKCS7_C). A user sdkconfig_options value takes precedence.""" + _mbedtls_sdkconfig().pkcs7_required = True def require_mbedtls_tls_server() -> None: - """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + """Widen the TLS role to include the server-side handshake. - Call this from components that accept TLS connections (OpenThread's DTLS - commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from - being selected. + Only affects builds where TLS is compiled in; it prevents + CONFIG_MBEDTLS_TLS_CLIENT_ONLY from being selected. A component that + actually opens or accepts TLS/DTLS sessions must also call request_tls(). """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + _mbedtls_sdkconfig().tls_server_required = True def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: @@ -1902,18 +2065,14 @@ def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: 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) + _mbedtls_sdkconfig().tls_extras_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. - - Call this from components that need to verify TLS certificates or signatures - using SHA-384 or SHA-512 algorithms. This prevents CONFIG_MBEDTLS_SHA384_C - and CONFIG_MBEDTLS_SHA512_C from being disabled. - """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_SHA512_REQUIRED] = True + """Keep mbedTLS SHA-384/SHA-512 (CONFIG_MBEDTLS_SHA384_C / CONFIG_MBEDTLS_SHA512_C).""" + _mbedtls_sdkconfig().sha512_required = True def idf_version() -> cv.Version: @@ -2032,6 +2191,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 ), @@ -2066,6 +2226,7 @@ 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, 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, @@ -2358,6 +2519,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. @@ -2417,34 +2592,103 @@ MBEDTLS_TLS_ROLE_OPTIONS = ( ) -@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. +# User sdkconfig_options that mean "keep TLS on" when set to y. The +# OpenThread entries compile its DTLS secure transport in, which links +# mbedtls_ssl_*. +_MBEDTLS_TLS_ON_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_ENABLED", + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_OPENTHREAD_COMMISSIONER", + "CONFIG_OPENTHREAD_JOINER", + "CONFIG_OPENTHREAD_BORDER_AGENT_ENABLE", + # Border router defaults the border agent (and its DTLS) on. + "CONFIG_OPENTHREAD_BORDER_ROUTER", + # Enterprise WiFi selects TLS back on (see the TLS-off block). + "CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", +) +# Any user option under these prefixes only makes sense with TLS compiled in. +_TLS_OPTION_PREFIXES = ("CONFIG_ESP_TLS_", "CONFIG_MBEDTLS_SSL_", "CONFIG_ESP_HTTPS_") - 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. + +def _user_sdkconfig_wants_tls(options: dict[str, Any]) -> bool: + """True when sdkconfig_options turn TLS on or tune something under it; an `n` is never a request.""" + return any( + (name in _MBEDTLS_TLS_ON_OPTIONS and value == "y") + or (name == "CONFIG_MBEDTLS_TLS_DISABLED" and value == "n") + or (name.startswith(_TLS_OPTION_PREFIXES) and value != "n") + for name, value in options.items() + ) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_sdkconfig() -> None: + """Reconcile the mbedTLS sdkconfig flags after every request_tls() / require_mbedtls_*() call. + + mbedtls cannot be excluded from an IDF build (bootloader_support needs its + SHA-256), but with no TLS user the ssl_*.c sources and the TLS-only crypto + compile to empty objects. When TLS stays in, it is trimmed to the client + role and the legacy handshake extras are dropped. User sdkconfig_options + win; a user-chosen TLS role leaves the whole choice alone. """ - 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) + data = _mbedtls_sdkconfig() + idf6 = idf_version() >= cv.Version(6, 0, 0) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + if _mbedtls_tls_compiled_out(): + # IDF 6 made CONFIG_MBEDTLS_TLS_ENABLED a normal bool; on IDF 5 it has + # no prompt and is only reachable through the "None" TLS role choice. + if idf6: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_ENABLED", False) + else: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_DISABLED", True) + # Enterprise WiFi selects TLS back on; wifi writes this itself, but + # esp_wifi can also be in the build without a wifi: block (openthread). + set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) + # WiFi (ESP_WIFI_MBEDTLS_CRYPTO) and Bluetooth deliberately stay on + # the select-wins path: an unconditional request from request_wifi() + # would defeat the ECP trim for users who disable that select. + if not data.ecp_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_ECP_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PEM_WRITE_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CRL_PARSE_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CSR_PARSE_C", False) + elif ( + # TLS stays in: trim it to the client role unless a component accepts + # TLS connections or the user already chose a role. + data.disable_tls_server + and not data.tls_server_required + and not any(option in opts 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()) + + # The extras run either way: CCM and deterministic ECDSA are plain + # crypto, not TLS-gated, so they matter even with TLS compiled out. + if data.disable_tls_extras: for option in MBEDTLS_TLS_EXTRA_OPTIONS: - if option not in required: + if option not in data.tls_extras_required: set_idf_sdkconfig_default(option, False) + # Keeping the peer certificate costs ~4KB heap per connection. + if data.peer_cert_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) + elif data.disable_peer_cert: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) + + if data.pkcs7_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", True) + elif data.disable_pkcs7: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", False) + + # SHA-384 shares the SHA-512 compression function, so both go together. + # Only IDF 6.0's PSA engine links a ~3KB software fallback for them; on + # IDF 5 they are a single hardware-only option with no code size cost. + if idf6 and not data.sha512_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA384_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA512_C", False) + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: @@ -2791,6 +3035,13 @@ async def to_code(config): "CONFIG_ESP32P4_SELECTS_REV_LESS_V3", config.get(CONF_ENGINEERING_SAMPLE, False), ) + # Work around ESP-IDF bug: see https://github.com/espressif/esp-idf/issues/19020 + add_idf_sdkconfig_option("CONFIG_ESP_MAIN_TASK_STACK_SIZE", 8192) + + # 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, @@ -2905,6 +3156,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 @@ -3049,6 +3311,10 @@ async def to_code(config): for key, flag in SIGNING_SCHEMES.items(): add_idf_sdkconfig_option(flag, scheme == key) + if scheme in (SIGNING_SCHEME_ECDSA256, SIGNING_SCHEME_ECDSA_V1): + # SECURE_SIGNED_APPS selects ECP in Kconfig anyway; requesting it + # keeps the resolved sdkconfig consistent with what ESPHome wrote. + require_mbedtls_ecp() if CONF_SIGNING_KEY in signed_ota: # Private key mode — auto-sign binaries during build @@ -3119,38 +3385,6 @@ async def to_code(config): if advanced[CONF_DISABLE_DEV_NULL_VFS]: add_idf_sdkconfig_option("CONFIG_VFS_INITIALIZE_DEV_NULL", False) - # Disable keeping peer certificate after TLS handshake - # Saves ~4KB heap per connection, but prevents certificate inspection after handshake - # Components that need it can call require_mbedtls_peer_cert() - if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PEER_CERT_REQUIRED, False): - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) - elif advanced[CONF_DISABLE_MBEDTLS_PEER_CERT]: - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) - - # Disable PKCS#7 support in mbedTLS - # Only needed for specific certificate validation scenarios - # Components that need it can call require_mbedtls_pkcs7() - if CORE.data[KEY_ESP32].get(KEY_MBEDTLS_PKCS7_REQUIRED, False): - # Component called require_mbedtls_pkcs7() - enable regardless of user setting - add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", True) - elif advanced[CONF_DISABLE_MBEDTLS_PKCS7]: - add_idf_sdkconfig_option("CONFIG_MBEDTLS_PKCS7_C", False) - - # Disable SHA-384 and SHA-512 in mbedTLS - # ESPHome doesn't use either algorithm. SHA-384 shares the same - # compression function as SHA-512 (mbedtls_internal_sha512_process), - # so both must be disabled to eliminate the ~3KB software fallback - # that IDF 6.0's PSA parallel engine always links in. - # On IDF < 6.0 these are a single config and hardware-only (no - # software fallback), so there was no code size cost to leaving - # them enabled. - # Components that need SHA-384/SHA-512 can call require_mbedtls_sha512() - if idf_version() >= cv.Version(6, 0, 0) and not CORE.data[KEY_ESP32].get( - KEY_MBEDTLS_SHA512_REQUIRED, False - ): - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA384_C", False) - add_idf_sdkconfig_option("CONFIG_MBEDTLS_SHA512_C", False) - # FINAL priority: runs after every require_libc_picolibc_newlib_compat() call CORE.add_job(_set_libc_picolibc_newlib_compat) @@ -3160,12 +3394,14 @@ 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 priority: runs after every request_tls() / require_mbedtls_*() call + mbedtls = _mbedtls_sdkconfig() + mbedtls.disable_tls = advanced[CONF_DISABLE_MBEDTLS_TLS] + mbedtls.disable_tls_server = advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER] + mbedtls.disable_tls_extras = advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS] + mbedtls.disable_peer_cert = advanced[CONF_DISABLE_MBEDTLS_PEER_CERT] + mbedtls.disable_pkcs7 = advanced[CONF_DISABLE_MBEDTLS_PKCS7] + CORE.add_job(_reconcile_mbedtls_sdkconfig) # 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. @@ -3198,6 +3434,8 @@ async def to_code(config): # so it still gets the CMN variant pinned. if conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE") == "y": require_certificate_bundle() + if _user_sdkconfig_wants_tls(conf[CONF_SDKCONFIG_OPTIONS]): + request_tls() # Components from YAML are added in a separate coroutine with FINAL priority # Schedule it to run after all other components @@ -3439,6 +3677,30 @@ def _write_idf_component_yml(): yml_path = CORE.relative_build_path("src/idf_component.yml") dependencies: dict[str, dict] = {} + converted: list[IDFComponent] = [] + if CORE.using_toolchain_esp_idf: + # Convert the PlatformIO libraries to ESP-IDF components as a batch so + # PlatformIO resolves the whole dependency tree at once -- deduplicating + # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c + # and esp_wireguard) to a single version instead of clashing + # override_path entries. + libraries = [ + library + for name, library in CORE.platformio_libraries.items() + # Don't process arduino libraries + if name not in ARDUINO_DISABLED_LIBRARIES + ] + # A library also declared as a managed component is not converted too, or + # IDF sees the same requirement twice; converted components reach it through + # ${ESPHOME_PROJECT_MANAGED_COMPONENTS}. + managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {})) + converted = generate_idf_components(libraries, managed=managed) + # IDF names a component after its directory and a later registration of the + # same name replaces the earlier one, so a stub beside a converted library of + # the same name (espressif/libsodium vs esphome/libsodium) would win or lose + # on path order. Such a stub points at the converted library instead. + converted_by_name = {component.path.name: component for component in converted} + # For Arduino builds, override unused managed components from the Arduino framework # by pointing them to empty stub directories using override_path # This prevents the IDF component manager from downloading the real components @@ -3452,9 +3714,7 @@ def _write_idf_component_yml(): } # Only stub components that are not required by any enabled Arduino library - components_to_stub = ( - set(ARDUINO_EXCLUDED_IDF_COMPONENTS) - required_idf_components - ) + components_to_stub = arduino_excluded_idf_components() - required_idf_components stubs_dir = CORE.relative_build_path("component_stubs") stubs_dir.mkdir(exist_ok=True) @@ -3464,8 +3724,17 @@ def _write_idf_component_yml(): # always writes, and ninja keeps triggering CMake re-runs on # otherwise-cached rebuilds. for component_name in sorted(components_to_stub): + stub_name = _idf_component_stub_name(component_name) + stub_path = stubs_dir / stub_name + if (component := converted_by_name.get(stub_name)) is not None: + if stub_path.exists(): + rmtree(stub_path) + dependencies[_idf_component_dep_name(component_name)] = { + "version": "*", + "override_path": str(component.path), + } + continue # Create stub directory with minimal CMakeLists.txt - stub_path = stubs_dir / _idf_component_stub_name(component_name) stub_path.mkdir(exist_ok=True) stub_cmake = stub_path / "CMakeLists.txt" if not stub_cmake.exists(): @@ -3507,22 +3776,10 @@ def _write_idf_component_yml(): ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), ) - if CORE.using_toolchain_esp_idf: - # Convert the PlatformIO libraries to ESP-IDF components as a batch so - # PlatformIO resolves the whole dependency tree at once -- deduplicating - # shared transitive deps (e.g. esphome/libsodium pulled by both noise-c - # and esp_wireguard) to a single version instead of clashing - # override_path entries. - libraries = [ - library - for name, library in CORE.platformio_libraries.items() - # Don't process arduino libraries - if name not in ARDUINO_DISABLED_LIBRARIES - ] - for component in generate_idf_components(libraries): - dependencies[component.get_sanitized_name()] = { - "override_path": str(component.path) - } + for component in converted: + dependencies[component.get_sanitized_name()] = { + "override_path": str(component.path) + } if CORE.data[KEY_ESP32][KEY_COMPONENTS]: components: dict = CORE.data[KEY_ESP32][KEY_COMPONENTS] diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index a0c9809c50c..33751ea7d52 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -23,6 +23,7 @@ KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_CERT_BUNDLE = "cert_bundle" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_NETWORK_SDKCONFIG = "network_sdkconfig" +KEY_MBEDTLS_SDKCONFIG = "mbedtls_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index a6916fe7394..f718d16df46 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -17,8 +17,7 @@ extern "C" __attribute__((weak)) void initArduino() {} namespace esphome { // HAL functions live in hal.cpp. This file keeps only the loop task setup. -TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static StackType_t loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -40,11 +39,10 @@ extern "C" void app_main() { initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE - loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, - &loop_task_tcb); + xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, &loop_task_tcb); #else - loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, - loop_task_stack, &loop_task_tcb, 1); + xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack, + &loop_task_tcb, 1); #endif } 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_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 81fa328c160..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 @@ -202,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) { @@ -239,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 " @@ -249,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 @@ -273,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(); @@ -393,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 @@ -416,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) { @@ -440,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"); diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index fd4fb15ff69..950ae6e0bc1 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -235,14 +235,14 @@ class ESP32BLE final : public Component { #ifdef USE_ESP32_BLE_ADVERTISING BLEAdvertising *advertising_{}; // 4 bytes (pointer) #endif - const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash) - esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) - uint32_t advertising_cycle_time_{}; // 4 bytes + const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash) + uint32_t advertising_cycle_time_{}; // 4 bytes // 2-byte aligned members uint16_t appearance_{0}; // 2 bytes // 1-byte aligned members (grouped together to minimize padding) + esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 1 byte (uint8_t typedef) BLEComponentState state_{BLE_COMPONENT_STATE_OFF}; // 1 byte (uint8_t enum) bool enable_on_boot_{}; // 1 byte #ifdef USE_ESP32_BLE_ADVERTISING 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_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 924d11db2b1..9355d588596 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,7 +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_MANUFACTURER +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 @@ -37,7 +37,6 @@ 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" 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_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 906144e5fde..59ac445cc06 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -8,6 +8,10 @@ from esphome import automation import esphome.codegen as cg from esphome.components import ble_device_base, esp32_ble, ota from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW +from esphome.components.ble_device_base.automation import ( + MAC_FILTER_LIST, + mac_filter_table, +) from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW from esphome.components.esp32 import ( add_idf_sdkconfig_option, @@ -39,8 +43,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 +119,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: @@ -284,7 +281,7 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( ESPBTAdvertiseTrigger ), - cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address), + cv.Optional(CONF_MAC_ADDRESS): MAC_FILTER_LIST, } ), cv.Optional( @@ -388,9 +385,8 @@ async def to_code(config: ConfigType) -> None: for conf in config.get(CONF_ON_BLE_ADVERTISE, []): _request_listener_slot() trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if CONF_MAC_ADDRESS in conf: - addr_list = [it.as_hex for it in conf[CONF_MAC_ADDRESS]] - cg.add(trigger.set_addresses(addr_list)) + if macs := conf.get(CONF_MAC_ADDRESS): + cg.add(trigger.set_addresses(mac_filter_table(macs))) await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf) for conf in config.get(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, []): _request_listener_slot() @@ -443,6 +439,25 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_ESP32_BLE_SOFTWARE_COEXISTENCE") +# First tagged release per series with espressif/esp-idf@82e71c1767 (see bluedroid_stubs.cpp). +# A series without an entry keeps the guard until a fixed release is tagged; the guard is +# harmless on fixed sources. The 5.4, 5.5 and 6.1 branches carry the fix but have no tag yet. +DIRECT_CONN_FIX_VERSIONS = { + (5, 2): cv.Version(5, 2, 8), + (5, 3): cv.Version(5, 3, 6), + (6, 0): cv.Version(6, 0, 3), +} +DIRECT_CONN_FIX_ALL_FROM = cv.Version(6, 2, 0) + + +def _needs_direct_conn_guard() -> bool: + ver = idf_version() + if ver >= DIRECT_CONN_FIX_ALL_FROM: + return False + fixed = DIRECT_CONN_FIX_VERSIONS.get((ver.major, ver.minor)) + return fixed is None or ver < fixed + + # This needs to be run as a job with very low priority so that all components have # chance to call register_ble_tracker and register_client before the list is checked # and added to the global defines list. @@ -459,6 +474,11 @@ async def _add_ble_features() -> None: if BLEFeatures.ESP_BT_DEVICE in required_features: cg.add_define("USE_ESP32_BLE_DEVICE") cg.add_define("USE_ESP32_BLE_UUID") + if cg.get_slot_count(CLIENT_COUNT_DEFINE) and _needs_direct_conn_guard(): + # --undefined keeps the wrapper, libsrc.a is scanned before the IDF libraries + cg.add_define("USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD") + cg.add_build_flag("-Wl,--wrap=l2cble_init_direct_conn") + cg.add_build_flag("-Wl,--undefined=__wrap_l2cble_init_direct_conn") ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema( @@ -469,23 +489,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 +506,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..f30f63a5c87 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -1,10 +1,9 @@ #pragma once #include "esphome/core/automation.h" +#include "esphome/components/ble_device_base/automation.h" #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" -#include - #ifdef USE_ESP32 namespace esphome::esp32_ble_tracker { @@ -12,22 +11,18 @@ namespace esphome::esp32_ble_tracker { class ESPBTAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } - void set_addresses(std::initializer_list addresses) { this->address_vec_ = addresses; } + /// Table of MACs ended by 0; must outlive the trigger. + void set_addresses(const uint64_t *addresses) { this->addresses_ = addresses; } bool parse_device(const ESPBTDevice &device) override { - uint64_t u64_addr = device.address_uint64(); - if (!address_vec_.empty()) { - if (std::find(address_vec_.begin(), address_vec_.end(), u64_addr) == address_vec_.end()) { - return false; - } - } - + if (this->addresses_ != nullptr && !ble_device_base::mac_in_table(this->addresses_, device.address_uint64())) + return false; this->trigger(device); return true; } protected: - std::vector address_vec_; + const uint64_t *addresses_{nullptr}; }; class BLEServiceDataAdvertiseTrigger final : public Trigger, public ESPBTDeviceListener { @@ -94,30 +89,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/bluedroid_stubs.cpp b/esphome/components/esp32_ble_tracker/bluedroid_stubs.cpp new file mode 100644 index 00000000000..5cedc22d069 --- /dev/null +++ b/esphome/components/esp32_ble_tracker/bluedroid_stubs.cpp @@ -0,0 +1,48 @@ +/* + * Bluedroid queues outgoing BLE connections as raw link block pointers and does + * not drop them when the block is released, so btm_send_pending_direct_conn() + * can start a connect on a released block and l2c_link_timeout() later crashes + * on its null timer parameter. Mirrors espressif/esp-idf@82e71c1767; codegen + * only enables it for releases without that commit. + */ + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD + +#include +#include +#include "esphome/core/log.h" + +namespace esphome::esp32_ble_tracker { +static const char *const TAG = "esp32_ble_tracker"; +} // namespace esphome::esp32_ble_tracker + +static_assert(ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 2, 0), + "ESP-IDF 6.2 and later have the fix, this guard should not be enabled (esphome/esphome#19373)"); + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +bool __real_l2cble_init_direct_conn(void *p_lcb); +void l2cu_release_lcb(void *p_lcb); + +bool __wrap_l2cble_init_direct_conn(void *p_lcb) { + // in_use is the first member of the private tL2C_LCB (checked ESP-IDF 5.0 to 6.1) + const auto *in_use = static_cast(p_lcb); + if (p_lcb == nullptr || *in_use == 0) { + ESP_LOGW(esphome::esp32_ble_tracker::TAG, "Dropped queued connect on a released link block"); + return false; + } + const bool started = __real_l2cble_init_direct_conn(p_lcb); + // Every failure path releases the block except unknown device, also fixed upstream + if (!started && *in_use != 0) { + l2cu_release_lcb(p_lcb); + } + return started; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index e25b6f59fa0..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 @@ -635,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; @@ -645,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 aa470983df9..6b71480ed3e 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -218,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_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 21626e432b6..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" @@ -145,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 @@ -155,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}", @@ -202,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] @@ -250,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( @@ -274,22 +412,11 @@ 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) - - # ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see - # the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not - # gated on the `espnow` component being present: the shim is tiny and the - # esp_now_* symbols/CustomRpc calls it defines require these Kconfig options - # to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so - # coupling the two keeps the build consistent. When `espnow` is absent the - # symbols are simply unused and never register a callback at runtime. + _configure_spi_2x(config) if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4: - add_define("USE_ESP_NOW_HOSTED") # esp-hosted's CustomRpc ("peer data transfer") path — off by default. esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True @@ -297,21 +424,67 @@ async def to_code(config: ConfigType) -> None: 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/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index d9b375dd20c..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) @@ -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/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index cef0e6ea11d..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 @@ -365,8 +448,8 @@ async def to_code(config: ConfigType) -> None: # 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" ) @@ -396,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] @@ -404,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]) ) @@ -445,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", @@ -503,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) @@ -588,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 87b76a392a6..7b36c410a25 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -4,7 +4,6 @@ #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 { @@ -37,19 +36,6 @@ class ESP8266PWM final : public output::FloatOutput, public Component { 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 be6e63b154d..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,7 +17,6 @@ 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 @@ -57,25 +54,13 @@ async def to_code(config: ConfigType) -> None: 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/espectre/__init__.py b/esphome/components/espectre/__init__.py new file mode 100644 index 00000000000..a46d6507fde --- /dev/null +++ b/esphome/components/espectre/__init__.py @@ -0,0 +1,185 @@ +from ipaddress import IPv4Address + +import esphome.codegen as cg +from esphome.components import esp32, wifi +from esphome.components.esp32.const import ( + VARIANT_ESP32, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32S2, + VARIANT_ESP32S3, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_WIFI +from esphome.core import CORE +from esphome.types import ConfigType + +DOMAIN = "espectre" +CODEOWNERS = ["@francescopace"] +DEPENDENCIES = ["esp32", "wifi"] + +CONF_ESPECTRE_ID = "espectre_id" +CONF_DETECTION_ALGORITHM = "detection_algorithm" +CONF_CSI_CAPTURE_PROFILE = "csi_capture_profile" +CONF_TRAFFIC_GENERATOR_MODE = "traffic_generator_mode" +CONF_TRAFFIC_GENERATOR_TARGET_IP = "traffic_generator_target_ip" +CONF_CSI_TRAFFIC_MULTICAST_GROUP = "csi_traffic_multicast_group" +CONF_MOTION_ON_HITS = "motion_on_hits" +CONF_MOTION_OFF_HITS = "motion_off_hits" + +# Fully qualified: a bare espectre:: in main.cpp would clash with the SDK namespace. +espectre_ns = cg.global_ns.namespace("esphome").namespace("espectre") +ESPectreComponent = espectre_ns.class_("ESPectreComponent", cg.Component) +sdk_ns = cg.global_ns.namespace("::espectre") +DetectionAlgorithm = sdk_ns.enum("DetectionAlgorithm", is_class=True) +CsiCapturePolicy = sdk_ns.enum("CsiCapturePolicy", is_class=True) +TrafficGeneratorMode = sdk_ns.enum("TrafficGeneratorMode", is_class=True) +WifiBandPolicy = sdk_ns.enum("WifiBandPolicy", is_class=True) + +DETECTION_ALGORITHMS = { + "lightweight": DetectionAlgorithm.LIGHTWEIGHT, + "high_accuracy": DetectionAlgorithm.HIGH_ACCURACY, +} +CSI_CAPTURE_PROFILES = { + "auto": CsiCapturePolicy.AUTO, + "lltf": CsiCapturePolicy.LLTF, + "ht_vht": CsiCapturePolicy.HT_VHT, +} +TRAFFIC_GENERATOR_MODES = { + "ping": TrafficGeneratorMode.PING, + "dns": TrafficGeneratorMode.DNS, + "dns_tcp": TrafficGeneratorMode.DNS_TCP, + "wifi_raw": TrafficGeneratorMode.WIFI_RAW, + "external": TrafficGeneratorMode.EXTERNAL_HOST, +} + + +def validate_target_ip(value: str) -> str: + value = str(cv.ipv4address(value)) + first_octet = int(IPv4Address(value)) >> 24 + if first_octet in (0, 127) or first_octet >= 224 or value == "255.255.255.255": + raise cv.Invalid("ESPectre traffic target must be a unicast IPv4 address") + return value + + +def validate_multicast_group(value: str) -> str: + """An IPv4 multicast group, or an empty string to skip joining one.""" + if not (value := cv.string_strict(value).strip()): + return value + value = str(cv.ipv4address(value)) + if not IPv4Address(value).is_multicast: + raise cv.Invalid("ESPectre multicast group must be an IPv4 multicast address") + return value + + +def supported_traffic_generator_modes(config: ConfigType) -> list[str]: + """Traffic generator modes available with this chip and CSI capture profile.""" + wifi_raw = ( + esp32.get_esp32_variant() != VARIANT_ESP32C6 + and config[CONF_CSI_CAPTURE_PROFILE] != "ht_vht" + ) + return [mode for mode in TRAFFIC_GENERATOR_MODES if wifi_raw or mode != "wifi_raw"] + + +def validate_config(config: ConfigType) -> ConfigType: + mode = config[CONF_TRAFFIC_GENERATOR_MODE] + if mode not in supported_traffic_generator_modes(config): + raise cv.Invalid( + "wifi_raw traffic is not supported on ESP32-C6 " + "or with the ht_vht CSI capture profile" + ) + if mode in ("wifi_raw", "external") and CONF_TRAFFIC_GENERATOR_TARGET_IP in config: + raise cv.Invalid(f"{mode} traffic does not use a target IP address") + if mode != "external" and CONF_CSI_TRAFFIC_MULTICAST_GROUP in config: + raise cv.Invalid( + f"{CONF_CSI_TRAFFIC_MULTICAST_GROUP} requires " + f"{CONF_TRAFFIC_GENERATOR_MODE}: external" + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ESPectreComponent), + cv.Optional(CONF_DETECTION_ALGORITHM, default="lightweight"): cv.enum( + DETECTION_ALGORITHMS, lower=True + ), + cv.Optional(CONF_CSI_CAPTURE_PROFILE, default="auto"): cv.enum( + CSI_CAPTURE_PROFILES, lower=True + ), + cv.Optional(CONF_TRAFFIC_GENERATOR_MODE, default="ping"): cv.enum( + TRAFFIC_GENERATOR_MODES, lower=True + ), + cv.Optional(CONF_TRAFFIC_GENERATOR_TARGET_IP): validate_target_ip, + cv.Optional(CONF_CSI_TRAFFIC_MULTICAST_GROUP): validate_multicast_group, + cv.Optional(CONF_MOTION_ON_HITS): cv.int_range(min=1, max=20), + cv.Optional(CONF_MOTION_OFF_HITS): cv.int_range(min=1, max=20), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[ + VARIANT_ESP32, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + ], + msg_prefix="ESPectre", + ), + # Arduino 3.3.7 is the first release built on ESP-IDF 5.5.3. + cv.require_framework_version( + esp_idf=cv.Version(5, 5, 3), esp32_arduino=cv.Version(3, 3, 7) + ), + validate_config, +) + + +def final_validate(config: ConfigType) -> None: + wifi.force_power_save_off( + "ESPectre needs the radio awake to receive a steady flow of CSI packets" + ) + + +FINAL_VALIDATE_SCHEMA = final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + cg.add_define("USE_ESPECTRE") + cg.add(var.set_detection_algorithm(config[CONF_DETECTION_ALGORITHM])) + cg.add(var.set_csi_capture_profile(config[CONF_CSI_CAPTURE_PROFILE])) + cg.add(var.set_traffic_generator_mode(config[CONF_TRAFFIC_GENERATOR_MODE])) + if (target_ip := config.get(CONF_TRAFFIC_GENERATOR_TARGET_IP)) is not None: + cg.add(var.set_traffic_generator_target_ip(target_ip)) + if (group := config.get(CONF_CSI_TRAFFIC_MULTICAST_GROUP)) is not None: + cg.add(var.set_csi_traffic_multicast_group(group)) + if (on_hits := config.get(CONF_MOTION_ON_HITS)) is not None: + cg.add(var.set_motion_on_hits(on_hits)) + if (off_hits := config.get(CONF_MOTION_OFF_HITS)) is not None: + cg.add(var.set_motion_off_hits(off_hits)) + if esp32.get_esp32_variant() == VARIANT_ESP32C5: + band = CORE.config[CONF_WIFI].get(wifi.CONF_BAND_MODE, "AUTO") + cg.add( + var.set_wifi_band_policy( + { + "2.4GHZ": WifiBandPolicy.BAND_2G, + "5GHZ": WifiBandPolicy.BAND_5G, + "AUTO": WifiBandPolicy.AUTO, + }[band] + ) + ) + + wifi.enable_runtime_roaming_suppression() + esp32.add_idf_component(name="francescopace/espectre", ref="3.0.0") + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_CSI_ENABLED", True) + # CSI is reported once per received transmission, so aggregation hides frames from sensing. + # Disabling TX aggregation also lets the SDK fix the station TX rate (6.5 Mbps on ESP32). + # Both may lower Wi-Fi throughput for the whole firmware. + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", False) + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", False) + # Keep the radio awake while disconnected too, matching force_power_save_off(). + esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE", False) diff --git a/esphome/components/espectre/binary_sensor.py b/esphome/components/espectre/binary_sensor.py new file mode 100644 index 00000000000..c012f823cfa --- /dev/null +++ b/esphome/components/espectre/binary_sensor.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_MOTION, DEVICE_CLASS_MOTION, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ESPECTRE_ID, ESPectreComponent + +DEPENDENCIES = ["espectre"] +CONF_CALIBRATING = "calibrating" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent), + cv.Optional(CONF_MOTION): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_MOTION + ), + cv.Optional(CONF_CALIBRATING): binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + } + ), + cv.has_at_least_one_key(CONF_MOTION, CONF_CALIBRATING), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ESPECTRE_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_MOTION, hub.set_motion_binary_sensor) + await binary_sensors(CONF_CALIBRATING, hub.set_calibrating_binary_sensor) diff --git a/esphome/components/espectre/button/__init__.py b/esphome/components/espectre/button/__init__.py new file mode 100644 index 00000000000..c6a167324e0 --- /dev/null +++ b/esphome/components/espectre/button/__init__.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns + +DEPENDENCIES = ["espectre"] + +RecalibrateButton = espectre_ns.class_("RecalibrateButton", button.Button) + +CONFIG_SCHEMA = button.button_schema( + RecalibrateButton, entity_category=ENTITY_CATEGORY_CONFIG +).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)}) + + +async def to_code(config: ConfigType) -> None: + var = await button.new_button(config) + await cg.register_parented(var, config[CONF_ESPECTRE_ID]) diff --git a/esphome/components/espectre/button/espectre_button.h b/esphome/components/espectre/button/espectre_button.h new file mode 100644 index 00000000000..f89c42f8fe9 --- /dev/null +++ b/esphome/components/espectre/button/espectre_button.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESPECTRE + +#include "esphome/components/button/button.h" +#include "../espectre.h" + +namespace esphome::espectre { + +class RecalibrateButton final : public button::Button, public Parented { + protected: + void press_action() override { this->parent_->recalibrate(); } +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/espectre.cpp b/esphome/components/espectre/espectre.cpp new file mode 100644 index 00000000000..0cce6a3b94d --- /dev/null +++ b/esphome/components/espectre/espectre.cpp @@ -0,0 +1,305 @@ +#include "espectre.h" + +#ifdef USE_ESPECTRE + +#include +#include +#include +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) +#include "esphome/components/wifi/wifi_component.h" +#endif + +namespace esphome::espectre { + +static const char *const TAG = "espectre"; + +struct TrafficModeOption { + const char *name; + ::espectre::TrafficGeneratorMode mode; +}; +// Option names match the traffic_generator_mode YAML values. +static constexpr TrafficModeOption TRAFFIC_MODE_OPTIONS[] = { + {"ping", ::espectre::TrafficGeneratorMode::PING}, + {"dns", ::espectre::TrafficGeneratorMode::DNS}, + {"dns_tcp", ::espectre::TrafficGeneratorMode::DNS_TCP}, + {"wifi_raw", ::espectre::TrafficGeneratorMode::WIFI_RAW}, + {"external", ::espectre::TrafficGeneratorMode::EXTERNAL_HOST}, +}; + +static const char *traffic_mode_name(::espectre::TrafficGeneratorMode mode) { + for (const auto &option : TRAFFIC_MODE_OPTIONS) { + if (option.mode == mode) + return option.name; + } + return nullptr; +} + +static const char *csi_capture_profile_name(::espectre::CsiCapturePolicy profile) { + switch (profile) { + case ::espectre::CsiCapturePolicy::LLTF: + return LOG_STR_LITERAL("lltf"); + case ::espectre::CsiCapturePolicy::HT_VHT: + return LOG_STR_LITERAL("ht_vht"); + default: + return LOG_STR_LITERAL("auto"); + } +} + +#ifdef USE_SELECT +void ESPectreComponent::request_traffic_generator_mode(const char *name) { + for (const auto &option : TRAFFIC_MODE_OPTIONS) { + if (strcmp(option.name, name) == 0) { + this->pending_traffic_mode_ = option.mode; + return; + } + } +} +#endif + +static int log_level(::espectre::LogLevel level) { + switch (level) { + case ::espectre::LogLevel::ERROR: + return ESPHOME_LOG_LEVEL_ERROR; + case ::espectre::LogLevel::WARNING: + return ESPHOME_LOG_LEVEL_WARN; + case ::espectre::LogLevel::INFO: + return ESPHOME_LOG_LEVEL_INFO; + case ::espectre::LogLevel::DEBUG: + return ESPHOME_LOG_LEVEL_DEBUG; + default: + return ESPHOME_LOG_LEVEL_VERBOSE; + } +} + +void ESPectreComponent::setup() { + // ESPHome owns scan results, including scans requested by the SDK's CSI recovery. + this->runtime_.config().wifi_scan_results_managed_externally = true; +#ifdef USE_SELECT + this->restore_traffic_mode_(); +#endif + this->start_runtime_(); +} + +void ESPectreComponent::start_runtime_() { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + // A sensor stays in one place, and each roaming scan takes the radio off-channel for + // seconds, emptying the CSI window. Losing the access point still reconnects normally. + if (wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->request_roaming_suppression(); + this->roaming_suppressed_ = true; + } +#endif + ::espectre::set_log_sink({ + .context = nullptr, + .enabled = [](void *, ::espectre::LogLevel level, const char *) { return log_level(level) <= ESPHOME_LOG_LEVEL; }, + .write = [](void *, ::espectre::LogLevel level, const char *tag, int line, const char *format, + va_list args) { esp_log_vprintf_(log_level(level), tag, line, format, args); }, + }); + if (!this->runtime_.setup(this)) { + ESP_LOGE(TAG, "Runtime setup failed"); + this->stop_(); + this->schedule_restart_(); + return; + } + this->running_ = true; + this->status_clear_error(); +#ifdef USE_SELECT + this->publish_traffic_mode_(); +#endif +} + +void ESPectreComponent::schedule_restart_() { + // A fault can be transient, such as a Wi-Fi stall, so retry instead of failing for good. + this->runtime_fault_ = false; + this->status_set_error(LOG_STR("Runtime stopped")); + ESP_LOGW(TAG, "Restarting the runtime in %" PRIu32 " s", RESTART_DELAY_MS / 1000); + this->restart_pending_ = true; + this->restart_requested_ms_ = App.get_loop_component_start_time(); +} + +#ifdef USE_SELECT +void ESPectreComponent::restore_traffic_mode_() { + if (this->traffic_mode_select_ == nullptr) + return; + // Keyed by the YAML mode, so changing it in YAML discards a mode saved from the select. + const auto yaml_mode = this->runtime_.config().traffic_generator_mode; + this->traffic_mode_pref_ = + this->traffic_mode_select_->make_entity_preference(static_cast(yaml_mode) + 1); + uint8_t saved; + if (!this->traffic_mode_pref_.load(&saved)) + return; + const auto mode = static_cast<::espectre::TrafficGeneratorMode>(saved); + const char *name = traffic_mode_name(mode); + if (name != nullptr && this->traffic_mode_select_->has_option(name)) + this->runtime_.config().traffic_generator_mode = mode; +} + +void ESPectreComponent::apply_pending_traffic_mode_() { + if (!this->pending_traffic_mode_.has_value()) + return; + const auto mode = *this->pending_traffic_mode_; + this->pending_traffic_mode_.reset(); + if (this->runtime_.set_traffic_generator_mode(mode)) { + const auto saved = static_cast(mode); + this->traffic_mode_pref_.save(&saved); + } else { + ESP_LOGW(TAG, "Traffic generator mode %s was rejected", traffic_mode_name(mode)); + } + this->publish_traffic_mode_(); +} + +void ESPectreComponent::publish_traffic_mode_() { + if (this->traffic_mode_select_ == nullptr) + return; + const char *name = traffic_mode_name(this->runtime_.config().traffic_generator_mode); + if (name != nullptr) + this->traffic_mode_select_->publish_state(name); +} +#endif + +void ESPectreComponent::loop() { + if (this->runtime_fault_) { + this->stop_(); + this->schedule_restart_(); + return; + } + if (!this->running_) { + // Without a backend this only reaps a traffic worker that outlived the last runtime. + this->runtime_.loop(); + if (this->restart_pending_ && + App.get_loop_component_start_time() - this->restart_requested_ms_ >= RESTART_DELAY_MS) { + this->restart_pending_ = false; + this->start_runtime_(); + } + return; + } + if (this->recalibrate_pending_) { + this->recalibrate_pending_ = false; + if (!this->runtime_.trigger_recalibration()) { + ESP_LOGW(TAG, "Recalibration is not available"); + } + } +#ifdef USE_SELECT + this->apply_pending_traffic_mode_(); +#endif + this->runtime_.loop(); + if (this->runtime_fault_) + return; + + // Read once after the SDK finishes dispatching callbacks, including readiness changes. + const auto &snapshot = this->runtime_.snapshot(); +#ifdef USE_BINARY_SENSOR + if (this->calibrating_binary_sensor_ != nullptr && + (!this->calibrating_published_ || this->calibrating_state_ != snapshot.calibrating)) { + this->calibrating_state_ = snapshot.calibrating; + this->calibrating_published_ = true; + this->calibrating_binary_sensor_->publish_state(snapshot.calibrating); + } +#endif + if (!snapshot.ready_to_publish) { + if (this->ready_) + this->invalidate_sensing_(); + this->movement_pending_ = false; + return; + } + +#ifdef USE_BINARY_SENSOR + const bool motion = snapshot.motion_state == ::espectre::MotionState::MOTION; + if (this->motion_binary_sensor_ != nullptr && (!this->ready_ || this->motion_state_ != motion)) { + this->motion_state_ = motion; + this->motion_binary_sensor_->publish_state(motion); + } +#endif +#ifdef USE_SENSOR + if (this->movement_sensor_ != nullptr && (this->movement_pending_ || !this->ready_)) + this->movement_sensor_->publish_state(snapshot.movement_metric); +#endif + this->movement_pending_ = false; + this->ready_ = true; +} + +void ESPectreComponent::invalidate_sensing_() { +#ifdef USE_BINARY_SENSOR + if (this->motion_binary_sensor_ != nullptr) + this->motion_binary_sensor_->invalidate_state(); +#endif +#ifdef USE_SENSOR + if (this->movement_sensor_ != nullptr) + this->movement_sensor_->publish_state(NAN); +#endif + this->ready_ = false; +} + +void ESPectreComponent::on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) { + if (success) { + this->calibrated_ = true; + this->status_clear_warning(); + ESP_LOGI(TAG, "Calibration complete"); + return; + } + // After a successful calibration, a failed one keeps that calibrated threshold. + if (!this->calibrated_) + this->status_set_warning(LOG_STR("Calibration failed")); + ESP_LOGW(TAG, "Calibration failed; retaining the previous threshold"); +} + +void ESPectreComponent::on_runtime_fault(const char *message) { + ESP_LOGE(TAG, "Runtime fault: %s", message); + this->runtime_fault_ = true; +} + +void ESPectreComponent::stop_() { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_ROAMING_SUPPRESSION) + if (this->roaming_suppressed_ && wifi::global_wifi_component != nullptr) { + wifi::global_wifi_component->release_roaming_suppression(); + } +#endif + this->roaming_suppressed_ = false; + this->running_ = false; + // The next runtime starts from the default threshold and publishes its calibration again. + this->calibrated_ = false; + this->calibrating_published_ = false; + this->runtime_.shutdown(); + ::espectre::clear_log_sink(); + this->invalidate_sensing_(); +#ifdef USE_BINARY_SENSOR + if (this->calibrating_binary_sensor_ != nullptr) + this->calibrating_binary_sensor_->invalidate_state(); +#endif +} + +void ESPectreComponent::on_shutdown() { + this->restart_pending_ = false; + this->stop_(); +} + +void ESPectreComponent::dump_config() { + const auto &config = this->runtime_.config(); + ESP_LOGCONFIG(TAG, + "ESPectre:\n" + " Detection algorithm: %s\n" + " CSI capture profile: %s\n" + " Traffic generator mode: %s\n" + " Motion on/off hits: %u/%u", + config.detection_algorithm == ::espectre::DetectionAlgorithm::LIGHTWEIGHT + ? LOG_STR_LITERAL("lightweight") + : LOG_STR_LITERAL("high_accuracy"), + csi_capture_profile_name(config.csi_capture_policy), traffic_mode_name(config.traffic_generator_mode), + config.motion_on_hits, config.motion_off_hits); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Motion", this->motion_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Calibrating", this->calibrating_binary_sensor_); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Movement score", this->movement_sensor_); +#endif +#ifdef USE_SELECT + LOG_SELECT(" ", "Traffic generator mode", this->traffic_mode_select_); +#endif +} + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/espectre.h b/esphome/components/espectre/espectre.h new file mode 100644 index 00000000000..e526c80cb41 --- /dev/null +++ b/esphome/components/espectre/espectre.h @@ -0,0 +1,110 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESPECTRE + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/core/optional.h" +#include +#include +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SELECT +#include "esphome/components/select/select.h" +#include "esphome/core/preferences.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif + +namespace esphome::espectre { + +class ESPectreComponent final : public Component, public ::espectre::IRuntimeListener { + public: + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + // Register the SDK's Wi-Fi event handlers after network setup, before station startup. + float get_setup_priority() const override { return setup_priority::WIFI + 1.0f; } + + void set_detection_algorithm(::espectre::DetectionAlgorithm value) { + this->runtime_.config().detection_algorithm = value; + } + void set_csi_capture_profile(::espectre::CsiCapturePolicy value) { + this->runtime_.config().csi_capture_policy = value; + } + void set_traffic_generator_mode(::espectre::TrafficGeneratorMode value) { + this->runtime_.config().traffic_generator_mode = value; + } + void set_traffic_generator_target_ip(const std::string &value) { + this->runtime_.config().traffic_generator_target_ip = value; + } + void set_csi_traffic_multicast_group(const std::string &value) { + this->runtime_.config().csi_traffic_multicast_group = value; + } + void set_motion_on_hits(uint8_t value) { this->runtime_.config().motion_on_hits = value; } + void set_motion_off_hits(uint8_t value) { this->runtime_.config().motion_off_hits = value; } + void set_wifi_band_policy(::espectre::WifiBandPolicy value) { this->runtime_.config().wifi_band_policy = value; } + // Queue controls so entity automations cannot re-enter the SDK from a listener callback. + void recalibrate() { this->recalibrate_pending_ = true; } +#ifdef USE_SELECT + void set_traffic_mode_select(select::Select *value) { this->traffic_mode_select_ = value; } + void request_traffic_generator_mode(::espectre::TrafficGeneratorMode mode) { this->pending_traffic_mode_ = mode; } + /// Request a mode by its traffic_generator_mode YAML name; unknown names are ignored. + void request_traffic_generator_mode(const char *name); +#endif + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(motion) + SUB_BINARY_SENSOR(calibrating) +#endif +#ifdef USE_SENSOR + SUB_SENSOR(movement) +#endif + /// Latest one-second runtime diagnostics sample, or nullptr before the runtime starts. + const ::espectre::RuntimeDiagnosticsSample *diagnostics_sample() const { return this->runtime_.diagnostics_sample(); } + + protected: + // Called once per SDK detector evaluation (every 250 ms), which bounds the movement publish rate. + void on_live_telemetry(const ::espectre::RuntimeSnapshot &snapshot) override { this->movement_pending_ = true; } + void on_calibration_finished(const ::espectre::RuntimeSnapshot &snapshot, bool success) override; + void on_runtime_fault(const char *message) override; + void start_runtime_(); + void schedule_restart_(); + void invalidate_sensing_(); + void stop_(); +#ifdef USE_SELECT + void restore_traffic_mode_(); + void apply_pending_traffic_mode_(); + void publish_traffic_mode_(); +#endif + + static constexpr uint32_t RESTART_DELAY_MS = 30000; + + ::espectre::RuntimeFrontendController runtime_; + uint32_t restart_requested_ms_{0}; +#ifdef USE_SELECT + select::Select *traffic_mode_select_{nullptr}; + ESPPreferenceObject traffic_mode_pref_; + optional<::espectre::TrafficGeneratorMode> pending_traffic_mode_; +#endif + bool recalibrate_pending_{false}; + bool movement_pending_{false}; + bool ready_{false}; + bool motion_state_{false}; + bool calibrating_state_{false}; + bool calibrating_published_{false}; + bool runtime_fault_{false}; + bool running_{false}; + bool restart_pending_{false}; + bool calibrated_{false}; + bool roaming_suppressed_{false}; +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/select/__init__.py b/esphome/components/espectre/select/__init__.py new file mode 100644 index 00000000000..8a4835e58b5 --- /dev/null +++ b/esphome/components/espectre/select/__init__.py @@ -0,0 +1,32 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.core import CORE +from esphome.types import ConfigType + +from .. import ( + CONF_ESPECTRE_ID, + DOMAIN, + ESPectreComponent, + espectre_ns, + supported_traffic_generator_modes, +) + +DEPENDENCIES = ["espectre"] + +TrafficModeSelect = espectre_ns.class_( + "TrafficModeSelect", select.Select, cg.Parented.template(ESPectreComponent) +) + +CONFIG_SCHEMA = select.select_schema( + TrafficModeSelect, entity_category=ENTITY_CATEGORY_CONFIG +).extend({cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent)}) + + +async def to_code(config: ConfigType) -> None: + options = supported_traffic_generator_modes(CORE.config[DOMAIN]) + var = await select.new_select(config, options=options) + await cg.register_parented(var, config[CONF_ESPECTRE_ID]) + parent = await cg.get_variable(config[CONF_ESPECTRE_ID]) + cg.add(parent.set_traffic_mode_select(var)) diff --git a/esphome/components/espectre/select/espectre_select.cpp b/esphome/components/espectre/select/espectre_select.cpp new file mode 100644 index 00000000000..9569b2955b5 --- /dev/null +++ b/esphome/components/espectre/select/espectre_select.cpp @@ -0,0 +1,11 @@ +#include "espectre_select.h" + +#ifdef USE_ESPECTRE + +namespace esphome::espectre { + +void TrafficModeSelect::control(size_t index) { this->parent_->request_traffic_generator_mode(this->option_at(index)); } + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/select/espectre_select.h b/esphome/components/espectre/select/espectre_select.h new file mode 100644 index 00000000000..3d27cfabf9e --- /dev/null +++ b/esphome/components/espectre/select/espectre_select.h @@ -0,0 +1,19 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESPECTRE + +#include "esphome/components/select/select.h" +#include "../espectre.h" + +namespace esphome::espectre { + +class TrafficModeSelect final : public select::Select, public Parented { + protected: + void control(size_t index) override; +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/sensor/__init__.py b/esphome/components/espectre/sensor/__init__.py new file mode 100644 index 00000000000..c0efac62042 --- /dev/null +++ b/esphome/components/espectre/sensor/__init__.py @@ -0,0 +1,78 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from .. import CONF_ESPECTRE_ID, ESPectreComponent, espectre_ns + +DEPENDENCIES = ["espectre"] + +CONF_MOVEMENT = "movement" +CONF_DIAGNOSTICS = "diagnostics" +CONF_GENERATOR_RATE = "generator_rate" +CONF_TRAFFIC_TX_RATE = "traffic_tx_rate" +CONF_TRAFFIC_RX_RATE = "traffic_rx_rate" +CONF_CSI_ACCEPTED_RATE = "csi_accepted_rate" +CONF_CSI_OCCUPANCY = "csi_occupancy" +UNIT_PACKETS_PER_SECOND = "pps" + +DiagnosticsUpdater = espectre_ns.class_("DiagnosticsUpdater", cg.PollingComponent) + + +def _diagnostic_schema(unit: str, accuracy_decimals: int) -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=unit, + accuracy_decimals=accuracy_decimals, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ) + + +DIAGNOSTIC_SENSORS = { + CONF_GENERATOR_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_TRAFFIC_TX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_TRAFFIC_RX_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_CSI_ACCEPTED_RATE: _diagnostic_schema(UNIT_PACKETS_PER_SECOND, 1), + CONF_CSI_OCCUPANCY: _diagnostic_schema(UNIT_PERCENT, 0), +} + +# Diagnostics are rarely watched, so they publish only on request unless an interval is set. +DIAGNOSTICS_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(DiagnosticsUpdater), + **{cv.Optional(key): schema for key, schema in DIAGNOSTIC_SENSORS.items()}, + } + ).extend(cv.polling_component_schema("never")), + cv.has_at_least_one_key(*DIAGNOSTIC_SENSORS), +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_ESPECTRE_ID): cv.use_id(ESPectreComponent), + cv.Optional(CONF_MOVEMENT): sensor.sensor_schema( + accuracy_decimals=3, state_class=STATE_CLASS_MEASUREMENT + ), + cv.Optional(CONF_DIAGNOSTICS): DIAGNOSTICS_SCHEMA, + } + ), + cv.has_at_least_one_key(CONF_MOVEMENT, CONF_DIAGNOSTICS), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ESPECTRE_ID]) + await sensor.sub_sensors(config)(CONF_MOVEMENT, hub.set_movement_sensor) + if (diagnostics_config := config.get(CONF_DIAGNOSTICS)) is not None: + updater = cg.new_Pvariable(diagnostics_config[CONF_ID], hub) + await cg.register_component(updater, diagnostics_config) + diagnostic_sensors = sensor.sub_sensors(diagnostics_config) + for key in DIAGNOSTIC_SENSORS: + await diagnostic_sensors(key, getattr(updater, f"set_{key}_sensor")) diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.cpp b/esphome/components/espectre/sensor/espectre_diagnostics.cpp new file mode 100644 index 00000000000..0edc3c07284 --- /dev/null +++ b/esphome/components/espectre/sensor/espectre_diagnostics.cpp @@ -0,0 +1,40 @@ +#include "espectre_diagnostics.h" + +#ifdef USE_ESPECTRE + +#include +#include "esphome/core/log.h" + +namespace esphome::espectre { + +static const char *const TAG = "espectre.sensor"; + +static void publish_diagnostic(sensor::Sensor *sensor, const ::espectre::RuntimeDiagnosticsSample *sample, + float ::espectre::RuntimeDiagnosticsSample::*field, float scale = 1.0f) { + if (sensor != nullptr) + sensor->publish_state(sample != nullptr ? sample->*field * scale : NAN); +} + +void DiagnosticsUpdater::update() { + using Sample = ::espectre::RuntimeDiagnosticsSample; + const auto *sample = this->parent_->diagnostics_sample(); + publish_diagnostic(this->generator_rate_sensor_, sample, &Sample::generator_pps); + publish_diagnostic(this->traffic_tx_rate_sensor_, sample, &Sample::traffic_tx_pps); + publish_diagnostic(this->traffic_rx_rate_sensor_, sample, &Sample::traffic_rx_pps); + publish_diagnostic(this->csi_accepted_rate_sensor_, sample, &Sample::csi_accepted_pps); + publish_diagnostic(this->csi_occupancy_sensor_, sample, &Sample::csi_occupancy_ratio, 100.0f); +} + +void DiagnosticsUpdater::dump_config() { + ESP_LOGCONFIG(TAG, "ESPectre diagnostics:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Generator rate", this->generator_rate_sensor_); + LOG_SENSOR(" ", "Traffic TX rate", this->traffic_tx_rate_sensor_); + LOG_SENSOR(" ", "Traffic RX rate", this->traffic_rx_rate_sensor_); + LOG_SENSOR(" ", "CSI accepted rate", this->csi_accepted_rate_sensor_); + LOG_SENSOR(" ", "CSI occupancy", this->csi_occupancy_sensor_); +} + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/espectre/sensor/espectre_diagnostics.h b/esphome/components/espectre/sensor/espectre_diagnostics.h new file mode 100644 index 00000000000..fde1c461137 --- /dev/null +++ b/esphome/components/espectre/sensor/espectre_diagnostics.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESPECTRE + +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" +#include "../espectre.h" + +namespace esphome::espectre { + +/// Publishes all diagnostic sensors together from the same runtime sample. +class DiagnosticsUpdater final : public PollingComponent { + public: + explicit DiagnosticsUpdater(ESPectreComponent *parent) : parent_(parent) {} + void update() override; + void dump_config() override; + + SUB_SENSOR(generator_rate) + SUB_SENSOR(traffic_tx_rate) + SUB_SENSOR(traffic_rx_rate) + SUB_SENSOR(csi_accepted_rate) + SUB_SENSOR(csi_occupancy) + + protected: + ESPectreComponent *parent_; +}; + +} // namespace esphome::espectre + +#endif // USE_ESPECTRE diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bcf2a2271cb..0cb2657ea1f 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 @@ -132,20 +133,12 @@ def ota_esphome_final_validate(config: ConfigType) -> None: _resolve_encryption_key(encryption_conf, api_conf) elif CONF_PASSWORD in ota_conf and static_encryption_key(api_conf) is not None: _LOGGER.warning( - "'%s' %s wastes significant flash and RAM (about 3.5 KB and 60 " - "bytes plus the password on the heap): the device already offers " - "encryption with the '%s' %s %s, which authenticates any uploader " - "that takes it, and a password only matters for uploaders without " - "encryption support; remove '%s' and add '%s' under '%s' so " - "uploads use the key and encryption is required", + "'%s' %s wastes significant flash and RAM; " + "using '%s' instead is recommended - " + "see https://esphome.io/components/ota/esphome/#configuration-variables", CONF_OTA, CONF_PASSWORD, - CONF_API, CONF_ENCRYPTION, - CONF_KEY, - CONF_PASSWORD, - CONF_ENCRYPTION, - CONF_OTA, ) elif ( CONF_PASSWORD in ota_conf @@ -231,15 +224,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: @@ -269,7 +285,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" ), @@ -291,10 +307,19 @@ FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate FILTER_SOURCE_FILES = filter_source_files_from_defines( - {"ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION"} + { + "ota_esphome_noise.cpp": "USE_OTA_ENCRYPTION", + "ota_esphome_inflate_session.cpp": "USE_OTA_DEFLATE", + "ota_esphome_inflate.c": "USE_OTA_DEFLATE", + } ) +def enable_deflate() -> None: + """Compile the on-the-fly inflater for compressed uploads.""" + cg.add_define("USE_OTA_DEFLATE") + + @coroutine_with_priority(CoroPriority.OTA_UPDATES) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) @@ -304,7 +329,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])) @@ -313,23 +341,23 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ALLOW_PARTITION_ACCESS): cg.add_define("USE_OTA_PARTITIONS") + # ESP8266 and RP2040 inflate gzip at reboot; the rest inflate on the fly + if not (CORE.is_esp8266 or CORE.is_rp2): + enable_deflate() + # 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(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 3010df10561..c399c0901c6 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 @@ -22,8 +22,10 @@ #include "esphome/core/lwip_fast_select.h" #endif +#include #include #include +#include #include namespace esphome { @@ -32,11 +34,13 @@ 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; @@ -45,6 +49,8 @@ static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds // 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; +static constexpr uint32_t OTA_PROGRESS_INTERVAL_MS = 1000; +static constexpr size_t OTA_SIZE_FIELD_BYTES = 4; // sizes on the wire are 4 bytes MSB first // Single-instance pointer — multi-port configs are rejected in final_validate. // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) @@ -58,6 +64,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")); @@ -182,12 +198,23 @@ static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04; static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08; +static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_DEFLATE = 0x10; // Noise needs the extended protocol: the prologue binds the 2-byte feature ack static constexpr uint8_t CLIENT_NOISE_FEATURES = CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02; static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04; +// Raw deflate, window <= OTA_INFLATE_WINDOW_SIZE. Binding once offered: the +// client must then send the image size frame and a deflate stream. +static constexpr uint8_t SERVER_FEATURE_SUPPORTS_DEFLATE = 0x08; + +#ifdef USE_OTA_ENCRYPTION +inline bool ESPHomeOTAComponent::noise_offered_() const { + return (this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 && + (this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES; +} +#endif inline bool ESPHomeOTAComponent::extended_proto_() const { #ifdef USE_OTA_ENCRYPTION_REQUIRED @@ -293,7 +320,7 @@ void ESPHomeOTAComponent::handle_handshake_() { this->transition_ota_state_(OTAState::FEATURE_ACK); const bool supports_compression = - (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression(); + (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && ota::OTABackend::supports_compression(); // Compose the feature-ack response. When the client negotiates the extended protocol we emit // a 2-byte response (marker + server feature flags); otherwise we emit the single-byte @@ -313,6 +340,28 @@ void ESPHomeOTAComponent::handle_handshake_() { #elif defined(USE_OTA_ENCRYPTION) // A yaml key always exists: validation rejects the all-zeros key this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE; +#endif +#ifdef USE_OTA_ENCRYPTION + // Reserve the noise session before the optional inflate buffer, so the + // required allocation is not starved by the compression window + if (this->noise_offered_()) { + this->noise_reserve_session_(); + } +#endif +#ifdef USE_OTA_DEFLATE + // Offered only once the session memory is in hand; else uncompressed + if ((this->ota_features_ & CLIENT_FEATURE_SUPPORTS_DEFLATE) != 0) { + // Value initialized: a corrupt stream that back references the + // window before it is filled then copies zeros, never stale memory. + // Default placement, PSRAM first where present: the session lives for one + // upload and keeps 4.9 KB of internal heap free while it runs + this->inflate_ = RAMAllocator().make_unique(); + if (this->inflate_ != nullptr) { + this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_DEFLATE; + } else { + ESP_LOGW(TAG, "No memory to inflate"); + } + } #endif } else { this->handshake_buf_[0] = @@ -331,8 +380,7 @@ void ESPHomeOTAComponent::handle_handshake_() { #ifdef USE_OTA_ENCRYPTION // Latch the offer actually sent: a key activating between the two // states must not start a session the client never expects - if ((this->handshake_buf_[1] & SERVER_FEATURE_SUPPORTS_NOISE) != 0 && - (this->ota_features_ & CLIENT_NOISE_FEATURES) == CLIENT_NOISE_FEATURES) { + if (this->noise_offered_()) { // handshake_buf_ still holds the feature ack composed above; a // would-block re-entry lands here without rebuilding it if (!this->noise_start_session_(this->handshake_buf_[1])) { @@ -430,16 +478,11 @@ void ESPHomeOTAComponent::handle_data_() { // Backend calls overwrite this with OK; reset to UNKNOWN before any // goto error that follows a successful begin()/write() ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; - size_t total = 0; - uint32_t last_progress = 0; - uint32_t last_data_ms = 0; + DataTransfer xfer; uint8_t buf[OTA_BUFFER_SIZE]; char *sbuf = reinterpret_cast(buf); - size_t ota_size; + size_t image_size; ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP; -#if USE_OTA_VERSION == 2 - size_t size_acknowledged = 0; -#endif // Set socket timeouts and blocking mode (see strategy table above) struct timeval tv; @@ -447,7 +490,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); @@ -455,6 +501,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) } @@ -463,13 +511,20 @@ void ESPHomeOTAComponent::handle_data_() { ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type); // Read size, 4 bytes MSB first - if (!this->data_readall_(buf, 4)) { + if (!this->read_size_(buf, xfer.ota_size, LOG_STR("size"))) { + // 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) } - ota_size = (static_cast(buf[0]) << 24) | (static_cast(buf[1]) << 16) | - (static_cast(buf[2]) << 8) | buf[3]; - ESP_LOGV(TAG, "Size is %zu bytes", ota_size); + image_size = xfer.ota_size; +#ifdef USE_OTA_DEFLATE + if (this->inflate_ != nullptr && !this->read_size_(buf, image_size, LOG_STR("image size"))) { + this->log_read_error_(LOG_STR("image size")); + goto error; // NOLINT(cppcoreguidelines-avoid-goto) + } +#endif #ifndef USE_OTA_PARTITIONS if (ota_type != ota::OTA_TYPE_UPDATE_APP) { @@ -489,7 +544,7 @@ void ESPHomeOTAComponent::handle_data_() { #endif // begin() returns quickly; flash sectors are erased incrementally during write(). - error_code = this->backend_->begin(ota_size, ota_type); + error_code = this->backend_->begin(image_size, ota_type); if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) @@ -509,75 +564,25 @@ void ESPHomeOTAComponent::handle_data_() { // Acknowledge MD5 OK - 1 byte this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK); - // Track when we last received data so a silently-vanished peer (no FIN/RST - // delivered, e.g. uploader killed mid-transfer or NAT/router dropped state) - // can't wedge the device indefinitely. Without this, the loop only exits - // on actual data, EOF, or a non-EWOULDBLOCK error from read(), and lwIP - // TCP keepalive isn't enabled here. - last_data_ms = millis(); - while (total < ota_size) { - if (millis() - last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { - ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); - error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + xfer.last_data_ms = millis(); +#ifdef USE_OTA_DEFLATE + if (this->inflate_ != nullptr) { + error_code = this->inflate_data_(buf, image_size, xfer); + if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - size_t remaining = ota_size - total; - size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE; - ssize_t read; -#ifdef USE_OTA_ENCRYPTION - if (this->noise_ != nullptr) { - // One frame per call; noise_read_data_ waits internally (readall_), so - // there is no would-block retry here and failures are already logged. - read = this->noise_read_data_(buf, requested); - if (read <= 0) { + } else +#endif + { + while (xfer.total < xfer.ota_size) { + ssize_t read = this->receive_data_(buf, xfer); + if (read < 0) { error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; goto error; // NOLINT(cppcoreguidelines-avoid-goto) } - } else -#endif - { - read = this->client_->read(buf, requested); - if (read == -1) { - const int err = errno; - if (this->would_block_(err)) { - // read() already waited up to SO_RCVTIMEO for data, just feed WDT - App.feed_wdt(); - continue; - } - ESP_LOGW(TAG, "Read err %d", err); - error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; + error_code = this->write_flash_(buf, read); + if (error_code != ota::OTA_RESPONSE_OK) goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } else if (read == 0) { - ESP_LOGW(TAG, "Remote closed"); - error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN; - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - } - - last_data_ms = millis(); - error_code = this->backend_->write(buf, read); - if (error_code != ota::OTA_RESPONSE_OK) { - ESP_LOGW(TAG, "Flash write err %d", error_code); - goto error; // NOLINT(cppcoreguidelines-avoid-goto) - } - total += read; -#if USE_OTA_VERSION == 2 - while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) { - this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK); - size_acknowledged += OTA_BLOCK_SIZE; - } -#endif - - uint32_t now = millis(); - if (now - last_progress > 1000) { - last_progress = now; - float percentage = (total * 100.0f) / ota_size; - ESP_LOGD(TAG, "Progress: %0.1f%%", percentage); -#ifdef USE_OTA_STATE_LISTENER - this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0); -#endif - // feed watchdog and give other tasks a chance to run - this->yield_and_feed_watchdog_(); + this->ack_written_(xfer); } } @@ -654,7 +659,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; @@ -700,7 +709,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]; @@ -774,6 +798,90 @@ bool ESPHomeOTAComponent::try_write_(size_t to_write, const LogString *desc) { return this->handshake_buf_pos_ >= to_write; } +bool ESPHomeOTAComponent::read_size_(uint8_t *buf, size_t &size, const LogString *desc) { + if (!this->data_readall_(buf, OTA_SIZE_FIELD_BYTES)) + return false; + size = encode_uint32(buf[0], buf[1], buf[2], buf[3]); + ESP_LOGV(TAG, "%s is %zu bytes", LOG_STR_ARG(desc), size); + return true; +} + +ota::OTAResponseTypes ESPHomeOTAComponent::write_flash_(uint8_t *data, size_t len) { + ota::OTAResponseTypes result = this->backend_->write(data, len); + if (result != ota::OTA_RESPONSE_OK) { + ESP_LOGW(TAG, "Flash write err %d", result); + } + return result; +} + +ssize_t ESPHomeOTAComponent::receive_data_(uint8_t *buf, DataTransfer &xfer) { + const size_t remaining = xfer.ota_size - xfer.total; + const size_t requested = std::min(remaining, OTA_BUFFER_SIZE); + ssize_t read; + for (;;) { + // A silently-vanished peer (no FIN/RST delivered, e.g. uploader killed + // mid-transfer or NAT/router dropped state) must not wedge the device: + // read() only fails on EOF or a real error, and lwIP TCP keepalive isn't + // enabled here. + if (millis() - xfer.last_data_ms > OTA_SOCKET_TIMEOUT_DATA) { + ESP_LOGW(TAG, "No data received for %u ms", (unsigned) OTA_SOCKET_TIMEOUT_DATA); + return -1; + } +#ifdef USE_OTA_ENCRYPTION + if (this->noise_ != nullptr) { + // One frame per call; noise_read_data_ waits internally (readall_), so + // 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")); + return -1; + } + break; + } +#endif + read = this->client_->read(buf, requested); + if (read > 0) + break; + if (read == 0) { + this->log_remote_closed_(LOG_STR("data")); + return -1; + } + if (!this->would_block_(errno)) { + this->log_socket_error_(LOG_STR("data")); + return -1; + } + // read() already waited up to SO_RCVTIMEO for data, just feed WDT + App.feed_wdt(); + } + + const uint32_t now = millis(); + xfer.last_data_ms = now; + xfer.total += read; + this->ack_received_(xfer); + if (now - xfer.last_progress > OTA_PROGRESS_INTERVAL_MS) { + xfer.last_progress = now; + float percentage = (xfer.total * 100.0f) / xfer.ota_size; + ESP_LOGD(TAG, "Progress: %0.1f%%", percentage); +#ifdef USE_OTA_STATE_LISTENER + this->notify_state_(ota::OTA_IN_PROGRESS, percentage, 0); +#endif + // feed watchdog and give other tasks a chance to run + this->yield_and_feed_watchdog_(); + } + return read; +} + +void ESPHomeOTAComponent::send_chunk_acks_(DataTransfer &xfer) { +#if USE_OTA_VERSION == 2 + while (xfer.acknowledged + OTA_BLOCK_SIZE <= xfer.total || + (xfer.total == xfer.ota_size && xfer.acknowledged < xfer.ota_size)) { + this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK); + xfer.acknowledged += OTA_BLOCK_SIZE; + } +#endif +} + void ESPHomeOTAComponent::cleanup_connection_() { this->client_->close(); this->client_ = nullptr; @@ -781,12 +889,16 @@ 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_(); #endif #ifdef USE_OTA_ENCRYPTION this->noise_ = nullptr; +#endif +#ifdef USE_OTA_DEFLATE + this->inflate_ = nullptr; #endif // Intentionally no disable_loop() — letting loop() run one more iteration catches // any connection that queued on the listener mid-session (otherwise the wake flag, diff --git a/esphome/components/esphome/ota/ota_esphome.h b/esphome/components/esphome/ota/ota_esphome.h index 68dd0ffb9ef..910a5b3d99d 100644 --- a/esphome/components/esphome/ota/ota_esphome.h +++ b/esphome/components/esphome/ota/ota_esphome.h @@ -7,6 +7,9 @@ #ifdef USE_OTA_ENCRYPTION #include "esphome/components/noise/noise_handshake.h" #endif +#ifdef USE_OTA_DEFLATE +#include "ota_esphome_inflate.h" +#endif #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/preferences.h" @@ -44,7 +47,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,8 +89,12 @@ 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; + // True once the feature ack offers noise and the client asked for it + bool noise_offered_() const; + void noise_reserve_session_(); bool noise_start_session_(uint8_t server_feature_flags); bool handle_noise_handshake_(); bool noise_try_read_frame_(); @@ -119,6 +126,38 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { return this->readall_(buf, len); } + // Upload accounting shared by the data loop and the inflate read callback + struct DataTransfer { + size_t ota_size{0}; // bytes the client sends + size_t total{0}; // bytes received so far +#if USE_OTA_VERSION == 2 + size_t acknowledged{0}; +#endif + uint32_t last_data_ms{0}; + uint32_t last_progress{0}; + }; + // Up to OTA_BUFFER_SIZE bytes into buf; returns bytes read, -1 on failure (logged) + ssize_t receive_data_(uint8_t *buf, DataTransfer &xfer); + // Raw lwIP cannot service the radio during a sector write, so the ack waits + // for the write there; a socket task lets the next block arrive meanwhile +#ifdef USE_SOCKET_IMPL_LWIP_TCP + static constexpr bool ACK_AFTER_WRITE = true; +#else + static constexpr bool ACK_AFTER_WRITE = false; +#endif + void send_chunk_acks_(DataTransfer &xfer); + inline void ack_received_(DataTransfer &xfer) { + if (!ACK_AFTER_WRITE) + this->send_chunk_acks_(xfer); + } + inline void ack_written_(DataTransfer &xfer) { + if (ACK_AFTER_WRITE) + this->send_chunk_acks_(xfer); + } + inline bool read_size_(uint8_t *buf, size_t &size, const LogString *desc); + // Writes to the backend and logs a failure + ota::OTAResponseTypes write_flash_(uint8_t *data, size_t len); + bool try_read_(size_t to_read, const LogString *desc); bool try_write_(size_t to_write, const LogString *desc); @@ -133,6 +172,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_(); @@ -148,8 +188,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { 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 RAMUniquePtr noise_; #endif // USE_OTA_ENCRYPTION @@ -171,6 +213,34 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { static_assert(OTA_BUFFER_SIZE >= NOISE_CLIENT_MAX_PLAINTEXT + noise::MAC_SIZE, "OTA_BUFFER_SIZE must fit a full encrypted data frame"); #endif +#ifdef USE_OTA_DEFLATE + // At least 1 << espota2.DEFLATE_WINDOW_BITS; also the inflate output buffer + static constexpr size_t OTA_INFLATE_WINDOW_SIZE = 4096; + // Heap-allocated only while a deflate upload is negotiated; the decoder + // state is the base so the read callback can recover the session + struct InflateSession : OtaInflateState { + // The session outlives the upload it serves, but these three are borrowed + // from inflate_data_'s caller and dangle once that call returns; only that + // call, and the flush and read callback it drives, may read them + ESPHomeOTAComponent *self; + DataTransfer *xfer; + uint8_t *in; // caller's buffer for the compressed input + size_t image_size; + size_t written; // inflated bytes in flash + size_t flushed; // bytes of the current window already in flash + ota::OTAResponseTypes error; // first failure inside the read callback + uint8_t window[OTA_INFLATE_WINDOW_SIZE]; + }; +#ifndef CLANG_TIDY // static analysis sets every define at once + static_assert(!ota::OTABackend::supports_compression(), + "USE_OTA_DEFLATE is for backends that cannot store a gzip image"); +#endif + // Writes the decoded bytes not yet in flash without moving dest + ota::OTAResponseTypes inflate_flush_(InflateSession &session); + ota::OTAResponseTypes inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer); + RAMUniquePtr inflate_; +#endif + static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45}; // Derived from the feature byte; storing it would pad the trailing bytes bool extended_proto_() const; @@ -183,6 +253,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_inflate.c b/esphome/components/esphome/ota/ota_esphome_inflate.c new file mode 100644 index 00000000000..1208f82ae89 --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_inflate.c @@ -0,0 +1,498 @@ +/* + * uzlib - tiny deflate/inflate library (deflate, gzip, zlib) + * + * Copyright (c) 2003 by Joergen Ibsen / Jibz + * All Rights Reserved + * http://www.ibsensoftware.com/ + * + * Copyright (c) 2014-2018 by Paul Sokolovsky + * + * This software is provided 'as-is', without any express + * or implied warranty. In no event will the authors be + * held liable for any damages arising from the use of + * this software. + * + * Permission is granted to anyone to use this software + * for any purpose, including commercial applications, + * and to alter it and redistribute it freely, subject to + * the following restrictions: + * + * 1. The origin of this software must not be + * misrepresented; you must not claim that you + * wrote the original software. If you use this + * software in a product, an acknowledgment in + * the product documentation would be appreciated + * but is not required. + * + * 2. Altered source versions must be plainly marked + * as such, and must not be misrepresented as + * being the original software. + * + * 3. This notice may not be removed or altered from + * any source distribution. + */ + +/* + * Altered for ESPHome: this is the raw deflate decoder from uzlib's + * tinflate.c (v2.9.5) with the gzip/zlib header parsers, checksums, + * runtime table builder and in-memory (non ring window) output path + * removed, and the public names prefixed with ota_inflate. + */ + +#include "ota_esphome_inflate.h" + +#include + +#define TINF_OK OTA_INFLATE_OK +#define TINF_DONE OTA_INFLATE_DONE +#define TINF_DATA_ERROR OTA_INFLATE_DATA_ERROR +#define TINF_DICT_ERROR OTA_INFLATE_DICT_ERROR +#define TINF_DATA struct OtaInflateState +#define TINF_TREE struct OtaInflateTree +#define TINF_ARRAY_SIZE(arr) (sizeof(arr) / sizeof(*(arr))) + +/* every output byte also goes into the ring window */ +#define TINF_PUT(d, c) \ + { \ + *d->dest++ = c; \ + d->dict_ring[d->dict_idx++] = c; \ + if (d->dict_idx == d->dict_size) \ + d->dict_idx = 0; \ + } + +/* --------------------------------------------------- * + * -- constant tables (upstream builds them at runtime) -- * + * --------------------------------------------------- */ + +static const unsigned char LENGTH_BITS[30] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, + 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5}; +static const unsigned short LENGTH_BASE[30] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, + 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258}; + +static const unsigned char DIST_BITS[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, + 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13}; +static const unsigned short DIST_BASE[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, + 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, + 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577}; + +/* special ordering of code length codes */ +static const unsigned char CLCIDX[] = {16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15}; + +/* ----------------------- * + * -- utility functions -- * + * ----------------------- */ + +/* given an array of code lengths, build a tree */ +static void tinf_build_tree(TINF_TREE *t, const unsigned char *lengths, unsigned int num) { + unsigned short offs[16]; + unsigned int i, sum; + + /* clear code length count table */ + for (i = 0; i < 16; ++i) + t->table[i] = 0; + + /* scan symbol lengths, and sum code length counts */ + for (i = 0; i < num; ++i) + t->table[lengths[i]]++; + + /* In the lengths array, 0 means unused code. So, t->table[0] now contains + number of unused codes. But table's purpose is to contain # of codes of + particular length, and there're 0 codes of length 0. */ + t->table[0] = 0; + + /* compute offset table for distribution sort */ + for (sum = 0, i = 0; i < 16; ++i) { + offs[i] = sum; + sum += t->table[i]; + } + + /* create code->symbol translation table (symbols sorted by code) */ + for (i = 0; i < num; ++i) { + if (lengths[i]) + t->trans[offs[lengths[i]]++] = i; + } +} + +/* ---------------------- * + * -- decode functions -- * + * ---------------------- */ + +static unsigned char uzlib_get_byte(TINF_DATA *d) { + /* If end of source buffer is not reached, return next byte from source + buffer. */ + if (d->source < d->source_limit) { + return *d->source++; + } + + /* Otherwise if there's callback and we haven't seen EOF yet, try to + read next byte using it. (Note: the callback can also update ->source + and ->source_limit). */ + if (!d->eof) { + int val = d->source_read_cb(d); + if (val >= 0) { + return (unsigned char) val; + } + } + + /* Otherwise, we hit EOF (either from ->source_read_cb() or from exhaustion + of the buffer), and it will be "sticky", i.e. further calls to this + function will end up here too. */ + d->eof = true; + + return 0; +} + +/* get one bit from source stream */ +static int tinf_getbit(TINF_DATA *d) { + unsigned int bit; + + /* check if tag is empty */ + if (!d->bitcount--) { + /* load next tag */ + d->tag = uzlib_get_byte(d); + d->bitcount = 7; + } + + /* shift bit out of tag */ + bit = d->tag & 0x01; + d->tag >>= 1; + + return bit; +} + +/* read a num bit value from a stream and add base */ +static unsigned int tinf_read_bits(TINF_DATA *d, int num, int base) { + unsigned int val = 0; + + /* read num bits */ + if (num) { + unsigned int limit = 1 << (num); + unsigned int mask; + + for (mask = 1; mask < limit; mask *= 2) + if (tinf_getbit(d)) + val += mask; + } + + return val + base; +} + +/* given a data stream and a tree, decode a symbol */ +static int tinf_decode_symbol(TINF_DATA *d, TINF_TREE *t) { + int sum = 0, cur = 0, len = 0; + + /* get more bits while code value is above sum */ + do { + cur = 2 * cur + tinf_getbit(d); + + if (++len == TINF_ARRAY_SIZE(t->table)) { + return TINF_DATA_ERROR; + } + + sum += t->table[len]; + cur -= t->table[len]; + + } while (cur >= 0); + + sum += cur; + if (sum < 0 || sum >= t->size) { + return TINF_DATA_ERROR; + } + + return t->trans[sum]; +} + +/* given a data stream, decode dynamic trees from it */ +static int tinf_decode_trees(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) { + /* code lengths for 288 literal/len symbols and 32 dist symbols */ + unsigned char lengths[288 + 32]; + unsigned int hlit, hdist, hclen, hlimit; + unsigned int i, num, length; + + /* get 5 bits HLIT (257-286) */ + hlit = tinf_read_bits(d, 5, 257); + + /* get 5 bits HDIST (1-32) */ + hdist = tinf_read_bits(d, 5, 1); + + /* get 4 bits HCLEN (4-19) */ + hclen = tinf_read_bits(d, 4, 4); + + for (i = 0; i < 19; ++i) + lengths[i] = 0; + + /* read code lengths for code length alphabet */ + for (i = 0; i < hclen; ++i) { + /* get 3 bits code length (0-7) */ + unsigned int clen = tinf_read_bits(d, 3, 0); + + lengths[CLCIDX[i]] = clen; + } + + /* build code length tree, temporarily use length tree */ + tinf_build_tree(lt, lengths, 19); + + /* decode code lengths for the dynamic trees */ + hlimit = hlit + hdist; + for (num = 0; num < hlimit;) { + int sym = tinf_decode_symbol(d, lt); + unsigned char fill_value = 0; + int lbits, lbase = 3; + + /* error decoding */ + if (sym < 0) + return sym; + + switch (sym) { + case 16: + /* copy previous code length 3-6 times (read 2 bits) */ + if (num == 0) + return TINF_DATA_ERROR; + fill_value = lengths[num - 1]; + lbits = 2; + break; + case 17: + /* repeat code length 0 for 3-10 times (read 3 bits) */ + lbits = 3; + break; + case 18: + /* repeat code length 0 for 11-138 times (read 7 bits) */ + lbits = 7; + lbase = 11; + break; + default: + /* values 0-15 represent the actual code lengths */ + lengths[num++] = sym; + /* continue the for loop */ + continue; + } + + /* special code length 16-18 are handled here */ + length = tinf_read_bits(d, lbits, lbase); + if (num + length > hlimit) + return TINF_DATA_ERROR; + for (; length; --length) { + lengths[num++] = fill_value; + } + } + + /* Check that there's "end of block" symbol */ + if (lengths[256] == 0) { + return TINF_DATA_ERROR; + } + + /* build dynamic trees */ + tinf_build_tree(lt, lengths, hlit); + tinf_build_tree(dt, lengths + hlit, hdist); + + return TINF_OK; +} + +/* build the fixed huffman trees (RFC 1951 3.2.6) through the generic tree + builder; altered from upstream, which unrolls them by hand */ +static void tinf_build_fixed_trees(TINF_TREE *lt, TINF_TREE *dt) { + unsigned char lengths[288]; + unsigned int i; + + for (i = 0; i < 144; ++i) + lengths[i] = 8; + for (; i < 256; ++i) + lengths[i] = 9; + for (; i < 280; ++i) + lengths[i] = 7; + for (; i < 288; ++i) + lengths[i] = 8; + tinf_build_tree(lt, lengths, 288); + + for (i = 0; i < 32; ++i) + lengths[i] = 5; + tinf_build_tree(dt, lengths, 32); +} + +/* ----------------------------- * + * -- block inflate functions -- * + * ----------------------------- */ + +/* given a stream and two trees, inflate next chunk of output (a byte or more) */ +static int tinf_inflate_block_data(TINF_DATA *d, TINF_TREE *lt, TINF_TREE *dt) { + if (d->curlen == 0) { + unsigned int offs; + int dist; + int sym = tinf_decode_symbol(d, lt); + + if (d->eof) { + return TINF_DATA_ERROR; + } + + if (sym < 0) { + return sym; + } + + /* literal byte */ + if (sym < 256) { + TINF_PUT(d, sym); + return TINF_OK; + } + + /* end of block */ + if (sym == 256) { + return TINF_DONE; + } + + /* substring from sliding dictionary */ + sym -= 257; + if (sym >= 29) { + return TINF_DATA_ERROR; + } + + /* possibly get more bits from length code */ + d->curlen = tinf_read_bits(d, LENGTH_BITS[sym], LENGTH_BASE[sym]); + + dist = tinf_decode_symbol(d, dt); + if (dist < 0 || dist >= 30) { + return TINF_DATA_ERROR; + } + + /* possibly get more bits from distance code */ + offs = tinf_read_bits(d, DIST_BITS[dist], DIST_BASE[dist]); + + /* calculate and validate actual LZ offset to use */ + if (offs > d->dict_size) { + return TINF_DICT_ERROR; + } + /* Note: we don't try to catch offset which points to not yet filled + part of the dictionary here. Doing so would require keeping another + variable to track "filled in" size of the dictionary. Appearance of + such an offset cannot lead to accessing memory outside of the + dictionary buffer, and clients which don't want to leak unrelated + information, should explicitly initialize dictionary buffer passed + to uzlib. */ + + d->lz_off = d->dict_idx - offs; + if (d->lz_off < 0) { + d->lz_off += d->dict_size; + } + } + + /* copy next byte from dict substring */ + TINF_PUT(d, d->dict_ring[d->lz_off]); + if ((unsigned) ++d->lz_off == d->dict_size) { + d->lz_off = 0; + } + d->curlen--; + return TINF_OK; +} + +/* inflate next byte from uncompressed block of data */ +static int tinf_inflate_uncompressed_block(TINF_DATA *d) { + if (d->curlen == 0) { + unsigned int length, invlength; + + /* get length */ + length = uzlib_get_byte(d); + length += 256 * uzlib_get_byte(d); + /* get one's complement of length */ + invlength = uzlib_get_byte(d); + invlength += 256 * uzlib_get_byte(d); + /* check length */ + if (length != (~invlength & 0x0000ffff)) + return TINF_DATA_ERROR; + + /* increment length to properly return TINF_DONE below, without + producing data at the same time */ + d->curlen = length + 1; + + /* make sure we start next block on a byte boundary */ + d->bitcount = 0; + } + + if (--d->curlen == 0) { + return TINF_DONE; + } + + unsigned char c = uzlib_get_byte(d); + TINF_PUT(d, c); + return TINF_OK; +} + +/* ---------------------- * + * -- public functions -- * + * ---------------------- */ + +/* initialize decompression structure */ +void ota_inflate_init(TINF_DATA *d, unsigned char *dict, unsigned int dict_len) { + d->source = NULL; + d->source_limit = NULL; + d->tag = 0; + d->eof = 0; + d->bitcount = 0; + d->lz_off = 0; + d->bfinal = 0; + d->btype = -1; + d->dict_size = dict_len; + d->dict_ring = dict; + d->dict_idx = 0; + d->curlen = 0; + d->ltree.trans = d->ltrans; + d->ltree.size = TINF_ARRAY_SIZE(d->ltrans); + d->dtree.trans = d->dtrans; + d->dtree.size = TINF_ARRAY_SIZE(d->dtrans); +} + +/* inflate next output bytes from compressed stream */ +int ota_inflate(TINF_DATA *d) { + do { + int res; + + /* start a new block */ + if (d->btype == -1) { + int old_btype; + next_blk: + old_btype = d->btype; + /* read final block flag */ + d->bfinal = tinf_getbit(d); + /* read block type (2 bits) */ + d->btype = tinf_read_bits(d, 2, 0); + + if (d->btype == 1 && old_btype != 1) { + /* build fixed huffman trees */ + tinf_build_fixed_trees(&d->ltree, &d->dtree); + } else if (d->btype == 2) { + /* decode trees from stream */ + res = tinf_decode_trees(d, &d->ltree, &d->dtree); + if (res != TINF_OK) { + return res; + } + } + } + + /* process current block */ + switch (d->btype) { + case 0: + /* decompress uncompressed block */ + res = tinf_inflate_uncompressed_block(d); + break; + case 1: + case 2: + /* decompress block with fixed/dynamic huffman trees */ + /* trees were decoded previously, so it's the same routine for both */ + res = tinf_inflate_block_data(d, &d->ltree, &d->dtree); + break; + default: + return TINF_DATA_ERROR; + } + + if (res == TINF_DONE && !d->bfinal) { + /* the block has ended (without producing more data), but we + can't return without data, so start procesing next block */ + goto next_blk; + } + + if (res != TINF_OK) { + return res; + } + + } while (d->dest < d->dest_limit); + + return TINF_OK; +} diff --git a/esphome/components/esphome/ota/ota_esphome_inflate.h b/esphome/components/esphome/ota/ota_esphome_inflate.h new file mode 100644 index 00000000000..9726508a303 --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_inflate.h @@ -0,0 +1,65 @@ +#pragma once +// Raw deflate decoder cut down from uzlib (https://github.com/pfalcon/uzlib, +// zlib licence, see the .c file); output goes through a ring window. + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum OtaInflateResult { + OTA_INFLATE_OK = 0, /* more data produced, call again */ + OTA_INFLATE_DONE = 1, /* end of compressed stream reached */ + OTA_INFLATE_DATA_ERROR = -3, + OTA_INFLATE_DICT_ERROR = -5, +}; + +struct OtaInflateTree { + uint16_t table[16]; /* table of code length counts */ + uint16_t *trans; /* code -> symbol translation table, size entries */ + uint16_t size; +}; + +struct OtaInflateState { + /* Next byte in the input buffer and one past its end */ + const unsigned char *source; + const unsigned char *source_limit; + /* Called when source is exhausted; returns the next byte or -1 at EOF. + It may refill source/source_limit for buffered operation. */ + int (*source_read_cb)(struct OtaInflateState *d); + /* Output cursor and one past the end of the output buffer */ + unsigned char *dest; + unsigned char *dest_limit; + /* Ring window holding the last dict_size output bytes for back references */ + unsigned char *dict_ring; + + unsigned int tag; + unsigned int curlen; + int lz_off; + unsigned int dict_size; + unsigned int dict_idx; + + /* One word: btype is -1 between blocks, bitcount never exceeds 7 */ + int8_t btype; + uint8_t bfinal; + uint8_t bitcount; + bool eof; + + struct OtaInflateTree ltree; /* dynamic length/symbol tree */ + struct OtaInflateTree dtree; /* dynamic distance tree */ + uint16_t ltrans[288]; + uint16_t dtrans[32]; /* the distance alphabet has 30 symbols, so the tree is kept small */ +}; + +/* dict must cover the encoder's window (its largest back reference) */ +void ota_inflate_init(struct OtaInflateState *d, unsigned char *dict, unsigned int dict_len); +/* Fills dest up to dest_limit (OK) or to the end of the stream (DONE). dest may + alias dict only if dest_limit - dest == dict_len and dest is reset to dict + exactly when a call returns OK, so the ring index and dest stay in lockstep */ +int ota_inflate(struct OtaInflateState *d); + +#ifdef __cplusplus +} +#endif diff --git a/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp b/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp new file mode 100644 index 00000000000..f3005cac488 --- /dev/null +++ b/esphome/components/esphome/ota/ota_esphome_inflate_session.cpp @@ -0,0 +1,94 @@ +#include "ota_esphome.h" +#ifdef USE_OTA +#ifdef USE_OTA_DEFLATE +#include "esphome/components/ota/ota_backend.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome { + +static const char *const TAG = "esphome.ota"; + +// The window doubles as the output buffer; flushed bytes stay as back +// reference history for the next windowful. +ota::OTAResponseTypes ESPHomeOTAComponent::inflate_flush_(InflateSession &session) { + const size_t produced = session.dest - session.window; + const size_t pending = produced - session.flushed; + if (pending != 0) { + if (pending > session.image_size - session.written) { + ESP_LOGW(TAG, "Inflate overrun"); + return ota::OTA_RESPONSE_ERROR_UNKNOWN; + } + ota::OTAResponseTypes result = this->write_flash_(session.window + session.flushed, pending); + if (result != ota::OTA_RESPONSE_OK) + return result; + session.flushed = produced; + session.written += pending; + // A compressible region yields many windows per socket read + App.feed_wdt(); + } + // Even with nothing new written: a block boundary can fall inside a header + this->ack_written_(*session.xfer); + return ota::OTA_RESPONSE_OK; +} + +ota::OTAResponseTypes ESPHomeOTAComponent::inflate_data_(uint8_t *in, size_t image_size, DataTransfer &xfer) { + InflateSession &session = *this->inflate_; + session.self = this; + session.xfer = &xfer; + session.in = in; + session.image_size = image_size; + session.written = 0; + session.error = ota::OTA_RESPONSE_OK; + ota_inflate_init(&session, session.window, OTA_INFLATE_WINDOW_SIZE); + // Where the ack must follow the write, flush and ack before waiting for + // input, or the client waits for an ack while the decoder waits for data + session.source_read_cb = [](OtaInflateState *d) -> int { + auto *s = static_cast(d); + if (ACK_AFTER_WRITE) { + s->error = s->self->inflate_flush_(*s); + if (s->error != ota::OTA_RESPONSE_OK) + return -1; + } + // More input than announced; reported by the size check below + if (s->xfer->total >= s->xfer->ota_size) + return -1; + ssize_t read = s->self->receive_data_(s->in, *s->xfer); + if (read <= 0) { + // Already logged by receive_data_ + s->error = ota::OTA_RESPONSE_ERROR_UNKNOWN; + return -1; + } + d->source = s->in + 1; + d->source_limit = s->in + read; + return s->in[0]; + }; + + int res; + do { + // The ring index wrapped to 0 exactly when the window filled + session.dest = session.window; + session.dest_limit = session.window + OTA_INFLATE_WINDOW_SIZE; + session.flushed = 0; + res = ota_inflate(&session); + // A stored block keeps emitting zeros after a failed read, hence eof + if (res < 0 || session.eof) + break; + session.error = this->inflate_flush_(session); + } while (res != OTA_INFLATE_DONE && session.error == ota::OTA_RESPONSE_OK); + + // Transport and flash failures are logged where they happen + if (session.error != ota::OTA_RESPONSE_OK) + return session.error; + if (res != OTA_INFLATE_DONE || session.written != image_size || xfer.total != xfer.ota_size) { + ESP_LOGW(TAG, "Inflate err %d, %zu of %zu B from %zu of %zu", res, session.written, image_size, xfer.total, + xfer.ota_size); + return ota::OTA_RESPONSE_ERROR_UNKNOWN; + } + ESP_LOGD(TAG, "Inflated %zu bytes from %zu", session.written, xfer.total); + return ota::OTA_RESPONSE_OK; +} + +} // namespace esphome +#endif // USE_OTA_DEFLATE +#endif // USE_OTA diff --git a/esphome/components/esphome/ota/ota_esphome_noise.cpp b/esphome/components/esphome/ota/ota_esphome_noise.cpp index 65476572a1e..b3d8501f100 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -32,7 +32,12 @@ ESPHomeOTAComponent::NoiseSession::~NoiseSession() { } } -/** Allocate the session and start the responder handshake. +void ESPHomeOTAComponent::noise_reserve_session_() { + // Default placement, PSRAM first where present: the session lives for one upload + this->noise_ = RAMAllocator().make_unique(); +} + +/** Start the responder handshake, on the session reserved at offer time. * * The prologue binds the whole plaintext preamble, so any tampering with the * negotiation (a stripped feature flag, a changed version) breaks the first @@ -41,9 +46,7 @@ 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 placement, PSRAM first where present: the session only lives for one upload - this->noise_ = RAMAllocator().make_unique(); + // session runs on the zero key load_psk fills in and fails the client's MAC 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 14d099ec062..dc36885f2dc 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -24,7 +24,6 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["network"] -byte_vector = cg.std_vector.template(cg.uint8) peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) espnow_ns = cg.esphome_ns.namespace("espnow") @@ -39,9 +38,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, @@ -232,12 +228,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,11 +303,12 @@ async def send_action( await register_peer(var, config, args) - data = config.get(CONF_DATA, []) + data = config[CONF_DATA] if isinstance(data, str): data = list(data.encode()) - templ = await cg.templatable(data, args, byte_vector, byte_vector) - cg.add(var.set_data(templ)) + await automation.templatable_bytes( + data, args, var.set_data_template, var.set_data_static, "espnow_data" + ) cg.add(var.set_wait_for_sent(config[CONF_WAIT_FOR_SENT])) cg.add(var.set_continue_on_error(config[CONF_CONTINUE_ON_ERROR])) @@ -323,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), @@ -364,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..851b8345c94 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -11,7 +11,7 @@ namespace esphome::espnow { template class SendAction final : public Action, public Parented { TEMPLATABLE_VALUE(peer_address_t, address); - TEMPLATABLE_VALUE(std::vector, data); + TEMPLATABLE_BYTES(data) public: void add_on_sent(const std::initializer_list *> &actions) { @@ -58,8 +58,10 @@ template class SendAction final : public Action, public P } }; peer_address_t address = this->address_.value(x...); - std::vector data = this->data_.value(x...); - esp_err_t err = this->parent_->send(address.data(), data, send_callback); + esp_err_t err = ESP_OK; + this->data_.visit( + [&](const uint8_t *data, size_t len) { err = this->parent_->send(address.data(), data, len, send_callback); }, + x...); if (err != ESP_OK) { send_callback(err); } else if (!this->flags_.wait_for_sent) { @@ -86,39 +88,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 3e7d345805c..ff05caca83e 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. @@ -536,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( { @@ -551,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])), @@ -628,6 +636,19 @@ def phy_register(address: int, value: int, page: int) -> cg.StructInitializer: ) +def _add_phy_registers(var: cg.MockObj, config: ConfigType) -> None: + if not (registers := config.get(CONF_PHY_REGISTERS)): + return + cg.add_define("ESPHOME_ETHERNET_PHY_REGISTER_COUNT", len(registers)) + for register_value in registers: + reg = phy_register( + register_value.get(CONF_ADDRESS), + register_value.get(CONF_VALUE), + register_value.get(CONF_PAGE_ID), + ) + cg.add(var.add_phy_register(reg)) + + @coroutine_with_priority(CoroPriority.COMMUNICATION) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) @@ -733,13 +754,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) - for register_value in config.get(CONF_PHY_REGISTERS, []): - reg = phy_register( - register_value.get(CONF_ADDRESS), - register_value.get(CONF_VALUE), - register_value.get(CONF_PAGE_ID), - ) - cg.add(var.add_phy_register(reg)) + _add_phy_registers(var, config) else: cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) @@ -748,13 +763,7 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN])) if CONF_POWER_PIN in config: cg.add(var.set_power_pin(config[CONF_POWER_PIN])) - for register_value in config.get(CONF_PHY_REGISTERS, []): - reg = phy_register( - register_value.get(CONF_ADDRESS), - register_value.get(CONF_VALUE), - register_value.get(CONF_PAGE_ID), - ) - cg.add(var.add_phy_register(reg)) + _add_phy_registers(var, config) # Register Ethernet with the esp32 sdkconfig reconciler. It disables the # WiFi stack and WiFi/BT coexistence only when Ethernet runs without WiFi, @@ -924,24 +933,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..44c4d6733d3 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 { @@ -192,7 +193,9 @@ class EthernetComponent final : public Component { void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; } void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; } void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; } - void add_phy_register(PHYRegister register_value); +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT + void add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } +#endif #endif // USE_ETHERNET_SPI #endif // USE_ESP32 @@ -219,6 +222,9 @@ class EthernetComponent final : public Component { protected: void start_connect_(); void finish_connect_(); +#if LWIP_IPV6 + esp_err_t ensure_ip6_linklocal_(); +#endif void dump_connect_params_(); #ifdef USE_ESP32 @@ -251,8 +257,10 @@ class EthernetComponent final : public Component { /// reset) and set the RGMII Tx/Rx clock delays needed for reliable data sampling. void yt8531_phy_init_(); #endif +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT /// @brief Set arbitratry PHY registers from config. void write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data); +#endif #ifdef USE_ETHERNET_SPI uint8_t clk_pin_; @@ -276,7 +284,9 @@ class EthernetComponent final : public Component { // Group all 32-bit members first int power_pin_{-1}; emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; - std::vector phy_registers_{}; +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT + StaticVector phy_registers_{}; +#endif // Group all 8-bit members together uint8_t clk_pin_{0}; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1d9903271ec..e9281453c90 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,13 @@ #include #include #include "esp_event.h" +#if USE_NETWORK_IPV6 +#include +#include +#endif +#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 +45,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 +82,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 +272,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 +300,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 +435,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: { @@ -412,9 +462,11 @@ void EthernetComponent::ethernet_lazy_init_() { } #endif // USE_ETHERNET_KSZ8081 +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); } +#endif #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #ifdef USE_ETHERNET_GENERIC @@ -453,6 +505,28 @@ 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 +#if USE_NETWORK_IPV6 && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // Since IDF 5.5 the internal EMAC drops multicast groups that were never added (before, + // it passed all multicast), and lwIP never adds all-nodes, so router advertisements + // were lost and SLAAC never ran. + { + uint8_t all_nodes[6] = {0x33, 0x33, 0x00, 0x00, 0x00, 0x01}; + if (esp_err_t filter_err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_ADD_MAC_FILTER, all_nodes); + filter_err != ESP_OK) { + ESP_LOGD(TAG, "IPv6 all-nodes multicast filter not added: %s", esp_err_to_name(filter_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 +628,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 +708,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() { @@ -688,6 +773,13 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base global_eth_component->notify_ip_state_listeners_(); } #endif +#if USE_NETWORK_IPV6 + // Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the + // link-local after a link flap, which clears the IPv6 addresses. + if (esp_err_t ll_err = esp_netif_create_ip6_linklocal(global_eth_component->eth_netif_); ll_err != ESP_OK) { + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed on link-up: %s", esp_err_to_name(ll_err)); + } +#endif /* USE_NETWORK_IPV6 */ break; case ETHERNET_EVENT_DISCONNECTED: event_name = "ETH disconnected"; @@ -724,7 +816,10 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ void *event_data) { ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data; ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip)); - global_eth_component->ipv6_count_ += 1; + // Count the addresses on the interface, not the events: recreating the link-local + // after a link flap fires another event for the same address. + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s); #if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) global_eth_component->connected_ = global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT); @@ -739,6 +834,29 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ } #endif /* USE_NETWORK_IPV6 */ +#if USE_NETWORK_IPV6 +// Create the link-local address unless the interface already has one, including one still in +// duplicate address detection: recreating it would restart DAD. esp_netif_get_ip6_linklocal() +// only reports a preferred address, so ask lwIP for the slot state instead. +esp_err_t EthernetComponent::ensure_ip6_linklocal_() { + if (auto *netif = static_cast(esp_netif_get_netif_impl(this->eth_netif_)); netif != nullptr) { + u8_t state; + { + LwIPLock lock; + state = netif_ip6_addr_state(netif, 0); + } + if (ip6_addr_istentative(state) || ip6_addr_isvalid(state)) { + return ESP_OK; + } + } + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created"); + } + return err; +} +#endif /* USE_NETWORK_IPV6 */ + void EthernetComponent::finish_connect_() { #if USE_NETWORK_IPV6 // Retry IPv6 link-local setup if it failed during initial connect @@ -749,10 +867,7 @@ void EthernetComponent::finish_connect_() { // - Cable unplugged/network interruption (#10705) // We can now retry since we're in CONNECTED state and the interface is definitely up. if (!this->ipv6_setup_done_) { - esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); - if (err == ESP_OK) { - ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); - } + this->ensure_ip6_linklocal_(); // Always set the flag to prevent continuous retries // If IPv6 setup fails here with the interface up and stable, it's // likely a persistent issue (IPv6 disabled at router, hardware @@ -766,7 +881,9 @@ void EthernetComponent::finish_connect_() { void EthernetComponent::start_connect_() { global_eth_component->got_ipv4_address_ = false; #if USE_NETWORK_IPV6 - global_eth_component->ipv6_count_ = 0; + // Recount rather than zero: addresses that survive a reconnect are not announced again. + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); this->ipv6_setup_done_ = false; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); @@ -845,7 +962,7 @@ void EthernetComponent::start_connect_() { // - At bootup when link isn't ready (#10281) // - After disconnection/cable unplugged (#10705) // We'll retry in finish_connect_() if it fails here. - err = esp_netif_create_ip6_linklocal(this->eth_netif_); + err = this->ensure_ip6_linklocal_(); if (err != ESP_OK) { if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { // This is a programming error, not a transient failure @@ -928,10 +1045,6 @@ void EthernetComponent::dump_connect_params_() { #endif /* USE_NETWORK_IPV6 */ } -#ifndef USE_ETHERNET_SPI -void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } -#endif - void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { if (!this->ethernet_initialized_) { // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless @@ -1029,6 +1142,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { } #endif // USE_ETHERNET_KSZ8081 +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { esp_err_t err; @@ -1053,6 +1167,7 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi } #endif } +#endif // ESPHOME_ETHERNET_PHY_REGISTER_COUNT #ifdef USE_ETHERNET_YT8531 void EthernetComponent::yt8531_phy_init_() { 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/exponential_moving_average/__init__.py b/esphome/components/exponential_moving_average/__init__.py new file mode 100644 index 00000000000..c58ce8a01e8 --- /dev/null +++ b/esphome/components/exponential_moving_average/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp new file mode 100644 index 00000000000..243e2cf31e1 --- /dev/null +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp @@ -0,0 +1,116 @@ +#include "exponential_moving_average_sensor.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::exponential_moving_average { + +static const char *const TAG = "exponential_moving_average"; + +const LogString *time_weighting_to_string(TimeWeighting weighting) { + switch (weighting) { + case TIME_WEIGHTING_PREVIOUS: + return LOG_STR("previous"); + case TIME_WEIGHTING_LINEAR: + return LOG_STR("linear"); + default: + return LOG_STR("new"); + } +} + +ScaledDuration scale_duration(uint32_t ms) { + if (ms < 1000) + return {static_cast(ms), LOG_STR("ms"), 0}; + if (ms < 60 * 1000) + return {ms / 1000.0f, LOG_STR("s"), 1}; + if (ms < 60 * 60 * 1000) + return {ms / (60 * 1000.0f), LOG_STR("min"), 1}; + return {ms / (60 * 60 * 1000.0f), LOG_STR("h"), 1}; +} + +void ExponentialMovingAverageSensor::setup() { + if (this->restore_) { + this->pref_ = this->make_entity_preference(); + float restored; + if (this->pref_.load(&restored) && std::isfinite(restored)) { + this->accumulator_ = restored; + this->publish_state(restored); + } + } + const uint32_t now = App.get_loop_component_start_time(); + this->last_update_ = now; + this->source_->add_on_state_callback( + [this](float value) { this->process_(value, App.get_loop_component_start_time()); }); + // The source may have published during its own setup(), before the callback was added. + if (this->source_->has_state()) + this->process_(this->source_->state, now); +} + +void ExponentialMovingAverageSensor::dump_config() { + LOG_SENSOR("", "Exponential Moving Average Sensor", this); + if (this->time_constant_ms_ != 0) { + const ScaledDuration time_constant = scale_duration(this->time_constant_ms_); + ESP_LOGCONFIG(TAG, + " Time Constant: %.*f %s\n" + " Time Weighting: %s", + time_constant.decimals, time_constant.value, LOG_STR_ARG(time_constant.unit), + LOG_STR_ARG(time_weighting_to_string(this->time_weighting_))); + } else { + ESP_LOGCONFIG(TAG, " Alpha: %.3f", this->alpha_); + } + ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_)); +} + +void ExponentialMovingAverageSensor::reset() { this->publish_and_save_(NAN); } + +void ExponentialMovingAverageSensor::process_(float value, uint32_t now) { + if (std::isnan(value)) + return; + // After a reboot the downtime is unknown, so the first interval is measured from setup(). + const uint32_t dt = now - this->last_update_; + this->last_update_ = now; + const float previous = this->previous_value_; + this->previous_value_ = value; + if (std::isnan(this->accumulator_)) { + this->publish_and_save_(value); + return; + } + if (this->time_constant_ms_ == 0) { + this->publish_and_save_(this->alpha_ * value + (1.0f - this->alpha_) * this->accumulator_); + return; + } + // Computed in double with expm1(): when the interval is short compared to the time constant, the weights are + // tiny and float rounding of exp() would swamp them. + const double x = static_cast(dt) / this->time_constant_ms_; + // The share of the old average replaced during this interval. + const double gain = -std::expm1(-x); + const double average = this->accumulator_; + // After a reboot there is no previous reading, so only the new value can be used. + const TimeWeighting weighting = std::isnan(previous) ? TIME_WEIGHTING_NEW : this->time_weighting_; + double result; + switch (weighting) { + case TIME_WEIGHTING_PREVIOUS: + result = average + gain * (previous - average); + break; + case TIME_WEIGHTING_LINEAR: { + // Exact result for a value moving in a straight line from the previous reading to the new one. + const double weight_new = x > 0.0 ? (x + std::expm1(-x)) / x : 0.0; + result = average + (gain - weight_new) * (previous - average) + weight_new * (value - average); + break; + } + default: + result = average + gain * (value - average); + break; + } + this->publish_and_save_(static_cast(result)); +} + +void ExponentialMovingAverageSensor::publish_and_save_(float value) { + this->accumulator_ = value; + this->publish_state(value); + if (this->restore_) + this->pref_.save(&value); +} + +} // namespace esphome::exponential_moving_average diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h new file mode 100644 index 00000000000..98bd88553bb --- /dev/null +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/preferences.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::exponential_moving_average { + +/// Which value is assumed to apply during the time between two readings, when a time constant is used. +enum TimeWeighting : uint8_t { + TIME_WEIGHTING_NEW = 0, + TIME_WEIGHTING_PREVIOUS, + TIME_WEIGHTING_LINEAR, +}; + +const LogString *time_weighting_to_string(TimeWeighting weighting); + +/// A duration in the largest of ms, s, min or h that keeps the value at 1 or more. +struct ScaledDuration { + float value; + const LogString *unit; + uint8_t decimals; +}; + +ScaledDuration scale_duration(uint32_t ms); + +class ExponentialMovingAverageSensor : public sensor::Sensor, public Component { + public: + explicit ExponentialMovingAverageSensor(sensor::Sensor *source) : source_(source) {} + + void setup() override; + void dump_config() override; + + void set_alpha(float alpha) { this->alpha_ = alpha; } + /// When non-zero, each sample is weighted by the time since the previous one instead of by a fixed alpha. + void set_time_constant(uint32_t time_constant_ms) { this->time_constant_ms_ = time_constant_ms; } + void set_time_weighting(TimeWeighting weighting) { this->time_weighting_ = weighting; } + void set_restore(bool restore) { this->restore_ = restore; } + /// Clear the average; the next sample starts it again. + void reset(); + + protected: + void process_(float value, uint32_t now); + void publish_and_save_(float value); + + sensor::Sensor *source_; + ESPPreferenceObject pref_; + float alpha_{0.1f}; + float accumulator_{NAN}; + float previous_value_{NAN}; + uint32_t time_constant_ms_{0}; + uint32_t last_update_{0}; + TimeWeighting time_weighting_{TIME_WEIGHTING_NEW}; + bool restore_{true}; +}; + +} // namespace esphome::exponential_moving_average diff --git a/esphome/components/exponential_moving_average/sensor.py b/esphome/components/exponential_moving_average/sensor.py new file mode 100644 index 00000000000..20c8135636d --- /dev/null +++ b/esphome/components/exponential_moving_average/sensor.py @@ -0,0 +1,103 @@ +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_ACCURACY_DECIMALS, + CONF_ALPHA, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_ID, + CONF_RESTORE, + CONF_SENSOR, + CONF_STATE_CLASS, + CONF_TIME_CONSTANT, + CONF_UNIT_OF_MEASUREMENT, +) +from esphome.core.entity_helpers import inherit_property_from +from esphome.types import ConfigType + +exponential_moving_average_ns = cg.esphome_ns.namespace("exponential_moving_average") +ExponentialMovingAverageSensor = exponential_moving_average_ns.class_( + "ExponentialMovingAverageSensor", sensor.Sensor, cg.Component +) + +TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting") +TIME_WEIGHTINGS: dict[str, cg.MockObj] = { + "new": TimeWeighting.TIME_WEIGHTING_NEW, + "previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS, + "linear": TimeWeighting.TIME_WEIGHTING_LINEAR, +} + +CONF_TIME_WEIGHTING: str = "time_weighting" + +DEFAULT_ALPHA: float = 0.1 + + +def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int: + # An average carries more precision than the individual readings. + return decimals + 1 + + +def validate_time_weighting(config: ConfigType) -> ConfigType: + if CONF_TIME_WEIGHTING in config and CONF_TIME_CONSTANT not in config: + raise cv.Invalid( + f"'{CONF_TIME_WEIGHTING}' can only be used with '{CONF_TIME_CONSTANT}'", + path=[CONF_TIME_WEIGHTING], + ) + return config + + +CONFIG_SCHEMA = cv.All( + sensor.sensor_schema(ExponentialMovingAverageSensor) + .extend( + { + cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_ALPHA): cv.All( + cv.float_, cv.Range(min=0, min_included=False, max=1) + ), + cv.Optional(CONF_TIME_CONSTANT): cv.positive_time_period_milliseconds, + cv.Optional(CONF_TIME_WEIGHTING): cv.enum(TIME_WEIGHTINGS, lower=True), + cv.Optional(CONF_RESTORE, default=True): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA), + cv.has_at_most_one_key(CONF_ALPHA, CONF_TIME_CONSTANT), + validate_time_weighting, +) + +FINAL_VALIDATE_SCHEMA = cv.All( + inherit_property_from(CONF_ICON, CONF_SENSOR), + inherit_property_from(CONF_UNIT_OF_MEASUREMENT, CONF_SENSOR), + inherit_property_from( + CONF_ACCURACY_DECIMALS, CONF_SENSOR, transform=inherit_accuracy_decimals + ), + inherit_property_from(CONF_DEVICE_CLASS, CONF_SENSOR), + inherit_property_from(CONF_STATE_CLASS, CONF_SENSOR), +) + + +async def to_code(config: ConfigType) -> None: + source = await cg.get_variable(config[CONF_SENSOR]) + var = cg.new_Pvariable(config[CONF_ID], source) + await cg.register_component(var, config) + await sensor.register_sensor(var, config) + + if (time_constant := config.get(CONF_TIME_CONSTANT)) is not None: + cg.add(var.set_time_constant(time_constant)) + if (weighting := config.get(CONF_TIME_WEIGHTING)) is not None: + cg.add(var.set_time_weighting(weighting)) + else: + cg.add(var.set_alpha(config.get(CONF_ALPHA, DEFAULT_ALPHA))) + cg.add(var.set_restore(config[CONF_RESTORE])) + + +automation.register_apply_action( + "sensor.exponential_moving_average.reset", + automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(ExponentialMovingAverageSensor), + } + ), + automation.ApplyCall("reset()"), +) 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/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/font/__init__.py b/esphome/components/font/__init__.py index 918fde5dbdd..732933ba480 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -41,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "font" MULTI_CONF = True +AUTO_LOAD = ["unicode"] CODEOWNERS = ["@esphome/core", "@clydebarrow"] @@ -726,7 +727,8 @@ async def to_code(config): ) ] - glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) + # constexpr Glyph table in flash; on ESP8266 static_const_array would place it in RAM. + glyphs = cg.progmem_array(config[CONF_RAW_GLYPH_ID], glyph_initializer) font_height = pt_to_px(base_font.size.height) ascender = pt_to_px(base_font.size.ascender) diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index fda9c269e51..2592370b6ad 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -3,6 +3,7 @@ #include "esphome/core/color.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/components/unicode/unicode.h" namespace esphome::font { static const char *const TAG = "font"; @@ -121,108 +122,6 @@ const Glyph *Font::get_glyph_data_(uint32_t unicode_letter) { } #endif -/** - * Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string. - * If successful, return the codepoint and set the length to the number of bytes read. - * If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to - * 0. - * - * @param utf8_str The input string - * @param length Pointer to length storage - * @return The extracted code point - */ -static uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { - // Safely cast to uint8_t* for correct bitwise operations on bytes - const uint8_t *current = reinterpret_cast(utf8_str); - uint32_t code_point = 0; - uint8_t c1 = *current++; - - // check for end of string - if (c1 == 0) { - *length = 0; - return 0; - } - - // --- 1-Byte Sequence: 0xxxxxxx (ASCII) --- - if (c1 < 0x80) { - // Valid ASCII byte. - code_point = c1; - // Optimization: No need to check for continuation bytes. - } - // --- 2-Byte Sequence: 110xxxxx 10xxxxxx --- - else if ((c1 & 0xE0) == 0xC0) { - uint8_t c2 = *current++; - - // Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx) - if ((c2 & 0xC0) != 0x80) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x1F) << 6; - code_point |= (c2 & 0x3F); - - // Error Check 2: Overlong check (2-byte must be > 0x7F) - if (code_point <= 0x7F) { - *length = 0; - return 0; - } - } - // --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx --- - else if ((c1 & 0xF0) == 0xE0) { - uint8_t c2 = *current++; - uint8_t c3 = *current++; - - // Error Check 1: Check continuation bytes - if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x0F) << 12; - code_point |= (c2 & 0x3F) << 6; - code_point |= (c3 & 0x3F); - - // Error Check 2: Overlong check (3-byte must be > 0x7FF) - // Also check for surrogates (0xD800-0xDFFF) - if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) { - *length = 0; - return 0; - } - } - // --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx --- - else if ((c1 & 0xF8) == 0xF0) { - uint8_t c2 = *current++; - uint8_t c3 = *current++; - uint8_t c4 = *current++; - - // Error Check 1: Check continuation bytes - if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) { - *length = 0; - return 0; - } - - code_point = (c1 & 0x07) << 18; - code_point |= (c2 & 0x3F) << 12; - code_point |= (c3 & 0x3F) << 6; - code_point |= (c4 & 0x3F); - - // Error Check 2: Overlong check (4-byte must be > 0xFFFF) - // Also check for valid Unicode range (must be <= 0x10FFFF) - if (code_point <= 0xFFFF || code_point > 0x10FFFF) { - *length = 0; - return 0; - } - } - // --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) --- - else { - *length = 0; - return 0; - } - *length = current - reinterpret_cast(utf8_str); - return code_point; -} - Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, uint8_t bpp) : glyphs_(ConstVector(data, data_nr)), @@ -271,7 +170,7 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in int x = 0; for (;;) { size_t length; - auto code_point = extract_unicode_codepoint(str, &length); + auto code_point = unicode::extract_unicode_codepoint(str, &length); if (length == 0) break; str += length; @@ -300,7 +199,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo int x_at = x_start; for (;;) { size_t length; - auto code_point = extract_unicode_codepoint(text, &length); + auto code_point = unicode::extract_unicode_codepoint(text, &length); if (length == 0) break; text += length; @@ -309,7 +208,8 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo // Unknown char, skip ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point); if (!this->glyphs_.empty()) { - uint8_t glyph_width = this->glyphs_[0].advance; + // Full-width read: a narrowing byte load would fault on a PROGMEM table on ESP8266. + int glyph_width = this->glyphs_[0].advance; display->rectangle(x_at, y_start, glyph_width, this->height_, color); x_at += glyph_width; } diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index fa24181bd00..bab7b449ba6 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/core/color.h" #include "esphome/core/datatypes.h" #include "esphome/core/defines.h" @@ -36,6 +38,18 @@ class Glyph final { int width; int height; }; +// The glyph table lives in flash, read with plain loads, which ESP8266 only allows for whole words. +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +static_assert(std::is_same_v, "Glyph fields must stay word sized"); +#ifdef USE_ESP8266 +static_assert(alignof(Glyph) == sizeof(uint32_t), "Glyph fields must stay word sized"); +static_assert(sizeof(Glyph) == 7 * sizeof(uint32_t), "Glyph is read from flash with word loads"); +#endif class Font final #ifdef USE_DISPLAY 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/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/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..10074fbf10f 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( @@ -58,9 +55,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if co2 := config.get(CONF_CO2): - sens = await sensor.new_sensor(co2) - cg.add(var.set_co2_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) cg.add(var.set_warmup_seconds(config[CONF_WARMUP_TIME])) @@ -73,20 +69,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/hdc1080/sensor.py b/esphome/components/hdc1080/sensor.py index b2b6dc533a2..c35488c96c3 100644 --- a/esphome/components/hdc1080/sensor.py +++ b/esphome/components/hdc1080/sensor.py @@ -48,10 +48,6 @@ 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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) diff --git a/esphome/components/hdc2010/sensor.py b/esphome/components/hdc2010/sensor.py index ad0311fb4fe..9837b9af333 100644 --- a/esphome/components/hdc2010/sensor.py +++ b/esphome/components/hdc2010/sensor.py @@ -48,10 +48,6 @@ 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)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/components/hdc2080/sensor.py b/esphome/components/hdc2080/sensor.py index b5388b4c2be..63625bbf948 100644 --- a/esphome/components/hdc2080/sensor.py +++ b/esphome/components/hdc2080/sensor.py @@ -49,10 +49,6 @@ 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(sens)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) 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..46e1a95cb12 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( { @@ -72,13 +66,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temp_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temp_config) - cg.add(var.set_temp_sensor(sens)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temp_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) cg.add(var.set_power_mode(config[CONF_POWER_MODE])) @@ -118,39 +108,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..22f0c12c8c2 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -1,6 +1,10 @@ #include "hoermann_hcp.h" +#include +#include + #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::hoermann_hcp { @@ -13,20 +17,17 @@ static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back b static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; -// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away. -static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; -// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the -// rest names the button - the low byte for the door commands, the second register for those that do not fit -// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each. -static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; -static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; -static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; -// The intermediate positions are named in the second register, so the first only carries the phase. -static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000}; -static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400}; -// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. -static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; +static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0110}; +static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0120}; +static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0140}; +static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0100, 0x4000}; +static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0100, 0x0400}; +// Absolute, as a vendor gateway sends them, so a late or repeated one cannot switch the lamp the wrong way. +static constexpr HoermannHcpCommand COMMAND_LIGHT_ON{"light on", 0x0880}; +static constexpr HoermannHcpCommand COMMAND_LIGHT_OFF{"light off", 0x0800, 0x0100}; +// Kept as the intent, as the same impulse starts a door at rest; only a door still moving at the fetch gets it. +static constexpr HoermannHcpCommand COMMAND_STOP{"stop", 0x0140}; // High byte of the state register and the door state it stands for. State 0x00 is decoded separately because // its low byte tells a plain stop from the vent position. @@ -60,6 +61,76 @@ static bool is_moving(DoorState state) { } } +static bool at_destination(const HoermannHcpCommand &command, DoorState state) { + if (&command == &COMMAND_OPEN) + return state == DoorState::OPEN; + if (&command == &COMMAND_CLOSE) + return state == DoorState::CLOSED; + if (&command == &COMMAND_VENT) + return state == DoorState::VENT; + if (&command == &COMMAND_HALF_OPEN) + return state == DoorState::HALF_OPEN; + return false; +} + +// Only a status poll's answer carries commands. +static constexpr uint8_t STATUS_COMMAND = 0x03; +// A second stop this soon after one went out is ignored, so a double press cannot restart the door. +static constexpr uint32_t STOP_LOCK_MS = 500; + +#ifdef USE_HOERMANN_HCP_IDENTITY +// 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. @@ -67,30 +138,44 @@ void HoermannHcp::update() { this->set_valid_(false); // Status broadcasts alone keep the connection alive, so a command the controller never fetches would // otherwise block every later one for as long as it keeps broadcasting. - if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { - // Dropping after the press was presented leaves the door without its release value, which is worth saying - // apart from a command the controller never looked at. - if (this->command_written_at_ != 0) { - ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press", - this->next_command_->name); - } else { - ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); - } + // A stop held for a door that has not reported its start yet is timed by the start window instead. + const bool stop_held = this->next_command_ == &COMMAND_STOP && this->starting_; + if (this->next_command_ != nullptr && !stop_held && now - this->command_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); this->drop_command_(); // Children may have assumed the command would land, so let them re-derive from the door. this->changed_ = true; } - // A target waits for a door still travelling the other way to turn around. If it never does, the target has - // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. - if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) { + // A target the door never started towards would otherwise cut a later move short. + if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->start_window_ms_) { ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); this->clear_target_(); } - // The door took the lamp key press but never reported the lamp changing, so stop expecting it to. - if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) { - ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); - this->forget_light_toggles_(); + // A door that never answers a fetched command is at rest after all. + if (this->starting_ && now - this->start_fetched_at_ > this->start_window_ms_) { + this->starting_ = false; + if (stop_held) { + ESP_LOGW(TAG, "Door did not report moving, dropping the stop"); + this->next_command_ = nullptr; + } else { + ESP_LOGD(TAG, "Door did not start after the command"); + } } + // A lamp command held while the door starts or moves waits on purpose, so its deadline starts once the door rests. + if (this->is_moving_or_starting_() && this->light_requested_ && !this->light_command_sent_) + this->light_since_ = now; + // Neither fire late nor block the next request. + if (this->light_requested_ && now - this->light_since_ > this->connection_timeout_ms_) { + if (this->light_command_sent_) { + ESP_LOGW(TAG, "Door did not report the lamp changing, giving up"); + } else { + ESP_LOGW(TAG, "Bus controller did not fetch the lamp command, dropping it"); + } + this->clear_light_request_(); + } +#ifdef USE_HOERMANN_HCP_IDENTITY + this->publish_identity_(); +#endif if (this->changed_) { this->changed_ = false; this->state_callback_.call(); @@ -102,6 +187,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, @@ -112,6 +203,15 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add } this->record_response_(); + const bool status_poll = std::exchange(this->status_poll_pending_, false); + +#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. @@ -123,8 +223,15 @@ modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_add // Command request: return the internal state, injecting any pending command. registers.push_back(counter); registers.push_back(static_cast(0x0001 | command)); - this->push_command_registers_(registers); + if (status_poll && static_cast(this->command_reg_value_) == STATUS_COMMAND) { + this->push_command_registers_(registers); + } else { + push_zeros(registers, 2); + } push_zeros(registers, 4); +#ifdef USE_HOERMANN_HCP_IDENTITY + this->add_identity_request_(registers, command); +#endif break; case 2: // Empty command request. @@ -156,6 +263,10 @@ 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]; + this->status_poll_pending_ = true; +#ifdef USE_HOERMANN_HCP_IDENTITY + this->transfer_answer_counter_ = this->take_identity_transfer_(registers); +#endif return {}; } @@ -187,36 +298,213 @@ modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, } void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { - const HoermannHcpCommand *command = this->next_command_; + const HoermannHcpCommand *command = this->take_command_(); + if (command == nullptr) + command = this->take_light_command_(); if (command == nullptr) { push_zeros(registers, 2); return; } - if (this->command_written_at_ == 0) { - // First read after the command was queued: present the "key pressed" values. - this->command_written_at_ = millis(); - ESP_LOGI(TAG, "Sending '%s' command to door", command->name); - registers.push_back(command->pressed_value); - registers.push_back(command->pressed_value_2); - return; - } - if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { - // Between the two events there is nothing to report, including in the second register. - push_zeros(registers, 2); - return; - } - // Enough time passed: present the "key released" values and clear the command. - ESP_LOGD(TAG, "Released '%s' command", command->name); - this->command_written_at_ = 0; - this->next_command_ = nullptr; - // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left - // to wait for, so it must not re-arm the watchdog. - if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0) - this->light_toggle_released_at_ = millis(); - registers.push_back(command->released_value); - registers.push_back(command->released_value_2); + ESP_LOGI(TAG, "Sending '%s' command to door", command->name); + registers.push_back(command->value); + registers.push_back(command->value_2); } +const HoermannHcpCommand *HoermannHcp::take_command_() { + const HoermannHcpCommand *command = this->next_command_; + if (command == nullptr) + return nullptr; + const bool moving = is_moving(this->door_state_); + // The door was just told to start and has not said so yet, so an impulse now could start it instead. + if (command == &COMMAND_STOP && this->starting_ && !moving) + return nullptr; + this->next_command_ = nullptr; + if (moving) { + // The door may have been started from elsewhere since the command was queued. + if ((command == &COMMAND_OPEN && this->door_state_ == DoorState::OPENING) || + (command == &COMMAND_CLOSE && this->door_state_ == DoorState::CLOSING)) { + ESP_LOGD(TAG, "Door is already moving that way, dropping '%s'", command->name); + return nullptr; + } + if (command != &COMMAND_STOP) { + ESP_LOGD(TAG, "Door is moving, stopping it instead of '%s'", command->name); + } + this->last_stop_at_ = millis(); + this->stop_sent_ = true; + return &COMMAND_STOP; + } + if (command == &COMMAND_STOP) { + ESP_LOGD(TAG, "Door came to rest before the stop was fetched, dropping it"); + return nullptr; + } + // Until the door answers, it still reads as at rest. + if (!this->door_state_seen_ || !at_destination(*command, this->door_state_)) { + this->starting_ = true; + this->start_command_ = command; + this->start_fetched_at_ = millis(); + } + return command; +} + +const HoermannHcpCommand *HoermannHcp::take_light_command_() { + // The motor ignores the lamp while its door moves and may switch it itself as it starts, so the lamp waits for rest. + if (!this->light_requested_ || this->light_command_sent_ || this->is_moving_or_starting_() || + this->light_target_ == this->light_on_) + return nullptr; + this->light_command_sent_ = true; + this->light_since_ = millis(); + return this->light_target_ ? &COMMAND_LIGHT_ON : &COMMAND_LIGHT_OFF; +} + +#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 command registers, so it waits while a command is pending. + if (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); @@ -275,56 +563,57 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name); return false; } + // A stop still waiting for a door that has come to rest would be dropped at the fetch anyway. + if (this->next_command_ == &COMMAND_STOP) + this->next_command_ = nullptr; if (this->next_command_ != nullptr) { ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller"); return false; } // A new command supersedes any half-open target the door was still travelling to. - if (command.clears_target) - this->clear_target_(); + this->clear_target_(); this->next_command_ = &command; this->command_queued_at_ = millis(); return true; } -bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } -bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } -bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } -bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); } -bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); } -bool HoermannHcp::toggle_light() { - if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { - ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); +bool HoermannHcp::is_moving_or_starting_() const { return this->starting_ || is_moving(this->door_state_); } + +bool HoermannHcp::command_door_(const HoermannHcpCommand &command) { + // Only stopped, so it is never reversed at speed. take_command_() drops one queued before the door started the same + // way. + if (this->is_moving_or_starting_()) + return this->stop_door(); + return this->queue_command_(command); +} + +bool HoermannHcp::open_door() { return this->command_door_(COMMAND_OPEN); } +bool HoermannHcp::close_door() { return this->command_door_(COMMAND_CLOSE); } +bool HoermannHcp::impulse_door() { return this->command_door_(COMMAND_IMPULSE); } +bool HoermannHcp::vent_door() { return this->command_door_(COMMAND_VENT); } +bool HoermannHcp::half_open_door() { return this->command_door_(COMMAND_HALF_OPEN); } +bool HoermannHcp::stop_door() { + this->clear_target_(); + // A stop outranks whatever is still waiting, so a door at rest does not start after the user pressed stop. + if (this->next_command_ != nullptr && this->next_command_ != &COMMAND_STOP) { + ESP_LOGD(TAG, "Stop cancels the unfetched '%s' command", this->next_command_->name); + this->next_command_ = nullptr; + } + if (!this->is_moving_or_starting_()) + return true; + if (!this->valid_) { + ESP_LOGW(TAG, "Not connected to the bus controller, dropping 'stop' command"); return false; } - if (!this->queue_command_(COMMAND_TOGGLE_LAMP)) - return false; - this->light_toggles_in_flight_++; - return true; -} -bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; } - -uint8_t HoermannHcp::unsent_light_toggles_() const { - return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0; -} - -bool HoermannHcp::cancel_light_toggle() { - // Once the pressed value has been presented the key press is already on the wire, so only an untouched - // command can be withdrawn. - if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0) - return false; - ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name); - this->drop_command_(); - return true; -} - -bool HoermannHcp::stop_door() { - if (!is_moving(this->door_state_)) { - this->clear_target_(); + if (this->next_command_ == &COMMAND_STOP) + return true; + if (this->stop_sent_ && millis() - this->last_stop_at_ < STOP_LOCK_MS) { + ESP_LOGD(TAG, "Door already stopping, ignoring stop"); return true; } - // On success queue_command_() clears the target; on refusal it stays armed so the next position retries. - return this->queue_command_(COMMAND_IMPULSE); + this->next_command_ = &COMMAND_STOP; + this->command_queued_at_ = millis(); + return true; } bool HoermannHcp::set_position(float position) { @@ -333,8 +622,8 @@ bool HoermannHcp::set_position(float position) { return this->close_door(); if (position >= OPEN_POSITION_THRESHOLD) return this->open_door(); - // Asking the door to travel to where it already is means stopping it. - if (position == this->current_position_) + // Asking the door to travel to where it already is, or anywhere while it moves, means stopping it. + if (position == this->current_position_ || this->is_moving_or_starting_()) return this->stop_door(); // The door itself has no notion of a target, so it is started in the right direction and stopped on the way. @@ -344,8 +633,6 @@ bool HoermannHcp::set_position(float position) { this->target_position_ = position; this->target_queued_at_ = millis(); this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; - // A door already travelling that way is on its way; one moving the other way has to turn around first. - this->target_started_ = this->door_state_ == this->target_direction_; return true; } @@ -366,54 +653,42 @@ void HoermannHcp::set_valid_(bool valid) { ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. this->drop_command_(); - // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards. - this->clear_target_(); - this->forget_light_toggles_(); + this->starting_ = false; + this->clear_light_request_(); // 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; } void HoermannHcp::drop_command_() { - const bool was_light_toggle = this->is_light_toggle_pending_(); - // Cleared first so the settling below no longer counts this command among the toggles still to be sent. this->next_command_ = nullptr; - this->command_written_at_ = 0; - if (was_light_toggle) { - // A lamp toggle says nothing about where the door was going, so it leaves the target alone. - this->light_toggle_settled_(); - } else { - this->clear_target_(); - } -} - -void HoermannHcp::light_toggle_settled_() { - if (this->light_toggles_in_flight_ == 0) - return; - this->light_toggles_in_flight_--; - // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for. - if (this->light_toggles_in_flight_ == this->unsent_light_toggles_()) - this->light_toggle_released_at_ = 0; - // The light was showing where the lamp was heading, so it has to be told to look again. - this->changed_ = true; -} - -void HoermannHcp::forget_light_toggles_() { - // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever. - this->light_toggle_released_at_ = 0; - // A toggle the door has not been shown yet is still going to fire, so it keeps counting. - const uint8_t unsent = this->unsent_light_toggles_(); - if (this->light_toggles_in_flight_ == unsent) - return; - this->light_toggles_in_flight_ = unsent; - this->changed_ = true; + this->clear_target_(); } 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; + } + // Only moving or the command's destination answers it, even as a first report that changes nothing. + if (this->starting_ && (is_moving(state) || at_destination(*this->start_command_, state))) { + // A stop held for the start is due now, so its fetch deadline starts here. + if (this->next_command_ == &COMMAND_STOP) + this->command_queued_at_ = millis(); + this->starting_ = false; + } if (this->door_state_ == state) return; this->door_state_ = state; this->changed_ = true; + if (!is_moving(state)) { + // A door at rest cannot be restarted by a second stop, as stop_door() sends nothing then. + this->stop_sent_ = false; + } this->update_current_position_(); if (!this->has_target_()) return; @@ -450,19 +725,48 @@ void HoermannHcp::set_light_on_(bool on) { return; this->light_on_ = on; this->changed_ = true; - if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) { - // The door has not been shown a toggle that could explain this, so the lamp was switched at the door. + if (!this->light_requested_) { ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on)); return; } - // The door acted, so one of the toggles it has seen has arrived. Any others still count. - this->light_toggle_settled_(); + if (on == this->light_target_) { + this->clear_light_request_(); + return; + } + // Switched away from the target while the command was out: send again. + this->light_command_sent_ = false; + this->light_since_ = millis(); +} + +bool HoermannHcp::set_light(bool on) { + // A known lamp implies a live connection. + if (!this->light_seen_) + return false; + this->light_target_ = on; + const bool was_requested = this->light_requested_; + // A sent command may still switch it away. + this->light_requested_ = on != this->light_on_ || this->light_command_sent_; + // The deadline belongs to the request, so more taps cannot keep it alive. + if (!was_requested) + this->light_since_ = millis(); + this->changed_ = true; + return true; +} + +void HoermannHcp::clear_light_request_() { + if (!this->light_requested_) + return; + this->light_requested_ = false; + this->light_command_sent_ = false; + this->changed_ = true; } void HoermannHcp::set_light_seen_(bool seen) { if (this->light_seen_ == seen) return; this->light_seen_ = seen; + if (!seen) + this->clear_light_request_(); // A resting door changes nothing else, so without this the light would never hear about it. this->changed_ = true; } diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 83be385c7b1..c16a0890ef9 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,20 +25,42 @@ enum class DoorState : uint8_t { STOPPED, }; -// 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. +#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 + +// Sent once, in a single status answer, as Hoermann's own bus accessory does. struct HoermannHcpCommand { const char *name; - uint16_t pressed_value; - uint16_t released_value; - uint16_t pressed_value_2{0x0000}; - uint16_t released_value_2{0x0000}; - // A door command supersedes a half-open target; the lamp has no bearing on where the door is going. - bool clears_target{true}; + uint16_t value; + uint16_t value_2{0x0000}; }; 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; @@ -60,47 +86,58 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool half_open_door(); bool stop_door(); bool set_position(float position); - bool toggle_light(); + // False while the door has not reported the lamp. + bool set_light(bool on); 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_; } // False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection // valid without saying anything about the lamp, so is_light_on() would still be its default. bool is_light_known() const { return this->light_seen_; } - // Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the - // lamp still reads as its old self, so this is what a request has to be judged against. - bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); } - // Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright - // instead of fighting it. Returns false if there is nothing to cancel. - bool cancel_light_toggle(); + // The requested state while switching, else the reported one. + bool is_light_heading_on() const { return this->light_requested_ ? this->light_target_ : this->light_on_; } protected: - // True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert. - bool is_light_toggle_pending_() const; - // Toggles the door has not been shown yet, which is at most the one still waiting in the command slot. - uint8_t unsent_light_toggles_() const; void record_response_(); + bool command_door_(const HoermannHcpCommand &command); // Returns false when the bus controller has not fetched the previous command yet. bool queue_command_(const HoermannHcpCommand &command); - // Throws away the pending command, taking any armed target with it unless the command was the lamp toggle. void drop_command_(); - // One outstanding toggle reached the lamp, was withdrawn, or was thrown away. - void light_toggle_settled_(); - // Stops expecting the toggles the door has already been shown to reach the lamp. - void forget_light_toggles_(); - // Appends the two key-press registers and advances the pending command's press/release state. + void clear_light_request_(); void push_command_registers_(modbus::RegisterValues ®isters); + // Decide at the fetch what goes into a status answer, against the door as it stands then. + const HoermannHcpCommand *take_command_(); + const HoermannHcpCommand *take_light_command_(); 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); // Recomputes the reported position from position_raw_ and the current door state. void update_current_position_(); bool has_target_() const { return this->target_position_ != 0.0f; } + bool is_moving_or_starting_() const; void clear_target_(); void set_light_on_(bool on); void set_light_seen_(bool seen); @@ -111,21 +148,24 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { // Position the door was told to travel to; 0.0 means no target is armed. float target_position_{0.0f}; - // Pending command / key-press state machine. const HoermannHcpCommand *next_command_{nullptr}; + const HoermannHcpCommand *start_command_{nullptr}; uint32_t command_queued_at_{0}; // Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline. uint32_t target_queued_at_{0}; - uint32_t command_written_at_{0}; uint32_t last_response_{0}; - // When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the - // wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline. - uint32_t light_toggle_released_at_{0}; + // Start of the wait for the fetch, then for the report. + uint32_t light_since_{0}; + uint32_t last_stop_at_{0}; + uint32_t start_fetched_at_{0}; + bool stop_sent_{false}; + // A door command was fetched and the door has not answered it by moving or reaching its destination yet. + bool starting_{false}; - // A command is "pressed" for this long before its end value is sent. - uint16_t key_press_delay_ms_{100}; // Drop the "connected" flag if the bus controller has not polled us for this long. uint16_t connection_timeout_ms_{2000}; + // A chosen margin for a door to report moving after a fetched command. + uint16_t start_window_ms_{5000}; // The state starts on a value the bus controller never reports, so the first broadcast is decoded even when // it reads 0x0000. uint16_t prev_state_reg_{0xFFFF}; @@ -134,18 +174,38 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { uint16_t command_reg_value_{0}; DoorState door_state_{DoorState::CLOSED}; - // Direction the door was started in for the current target. A target armed while the door is still travelling - // the other way must not be judged by the reported direction until the door has turned around. + // Direction the door was started in for the current target, judged only once the door reports moving that way. DoorState target_direction_{DoorState::STOPPED}; // Position as reported by the bus controller, 0..200 across the full travel. uint8_t position_raw_{0}; - uint8_t light_toggles_in_flight_{0}; bool target_started_{false}; bool valid_{false}; bool changed_{false}; bool light_on_{false}; bool light_seen_{false}; + bool light_requested_{false}; + bool light_command_sent_{false}; + bool light_target_{false}; + bool door_state_seen_{false}; bool short_broadcast_logged_{false}; + // Only the read half right after a 0x17 write carries a command, so a second read without a new write does not. + bool status_poll_pending_{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/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp index d3d784928d3..f7e40b89210 100644 --- a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -37,15 +37,10 @@ void HoermannHcpLight::write_state(light::LightState *state) { if (restored) { ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing"); } else if (published != binary) { - if (!this->parent_->is_light_known()) { - // Commanding a lamp that has not been read could switch off one that is already on. - ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); - } else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) { - // A toggle the controller has not fetched is withdrawn outright rather than fought with a second one. + // Refused until the door has reported the lamp, as commanding an unread one could switch off a lit lamp. + if (this->parent_->set_light(binary)) return; - } else { - ESP_LOGW(TAG, "Light command was not accepted by the door"); - } + ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); } // Nothing was sent, so the entity has to go back to showing the lamp rather than the request. this->publish_lamp_state_(heading_on); 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..da460905c0e --- /dev/null +++ b/esphome/components/hoermann_hcp/text_sensor/__init__.py @@ -0,0 +1,47 @@ +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: + hub = 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, hub) + 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") + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_SERIAL_NUMBER, hub.set_serial_number_text_sensor) + await text_sensors(CONF_VERSION, hub.set_version_text_sensor) 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/homeassistant/__init__.py b/esphome/components/homeassistant/__init__.py index 1b66842f1eb..9fead561af3 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -2,8 +2,9 @@ from collections.abc import Callable, Iterable import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL +from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_ID, CONF_INTERNAL from esphome.cpp_generator import MockObj +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter", "@esphome/core"] @@ -14,10 +15,12 @@ def validate_entity_domain( platform: str, supported_domains: Iterable[str] ) -> Callable[[ConfigType], ConfigType]: def validator(config: ConfigType) -> ConfigType: - domain = config[CONF_ENTITY_ID].split(".", 1)[0] - if domain not in supported_domains: + # A wizard input supplies the entity ID later; the wizard checks its domains + if (entity_id := config.get(CONF_ENTITY_ID)) is None: + return config + if entity_id.split(".", 1)[0] not in supported_domains: raise cv.Invalid( - f"Entity ID {config[CONF_ENTITY_ID]} is not supported by the {platform} platform." + f"Entity ID {entity_id} is not supported by the {platform} platform." ) return config @@ -26,7 +29,7 @@ def validate_entity_domain( HOME_ASSISTANT_IMPORT_SCHEMA = cv.Schema( { - cv.Required(CONF_ENTITY_ID): cv.entity_id, + cv.Optional(CONF_ENTITY_ID): cv.entity_id, cv.Optional(CONF_ATTRIBUTE): cv.string, cv.Optional(CONF_INTERNAL, default=True): cv.boolean, } @@ -34,14 +37,35 @@ HOME_ASSISTANT_IMPORT_SCHEMA = cv.Schema( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema( { - cv.Required(CONF_ENTITY_ID): cv.entity_id, + cv.Optional(CONF_ENTITY_ID): cv.entity_id, cv.Optional(CONF_INTERNAL, default=True): cv.boolean, } ) +def final_validate_entity_id(config: ConfigType) -> ConfigType: + """Without an entity_id, the entity must be a wizard input, as Home Assistant then supplies it.""" + if CONF_ENTITY_ID in config: + return config + from esphome.components.api import wizard + + if config[CONF_ID].id not in wizard.wizard_input_ids( + fv.full_config.get()[wizard.API_DOMAIN] + ): + raise cv.Invalid( + f"{CONF_ENTITY_ID} is required unless this entity is a wizard input" + ) + return config + + def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None: - cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) + from esphome.components.api import wizard + + if (buffer := wizard.wizard_input_buffer(config[CONF_ID])) is not None: + # The entity ID is chosen in the wizard and lives in a buffer the API owns + cg.add(var.set_entity_id(cg.RawExpression(buffer))) + else: + cg.add(var.set_entity_id(config[CONF_ENTITY_ID])) if CONF_ATTRIBUTE in config: cg.add(var.set_attribute(config[CONF_ATTRIBUTE])) cg.add_define("USE_API_HOMEASSISTANT_STATES") diff --git a/esphome/components/homeassistant/binary_sensor/__init__.py b/esphome/components/homeassistant/binary_sensor/__init__.py index 6ea17b68313..310ade27a8e 100644 --- a/esphome/components/homeassistant/binary_sensor/__init__.py +++ b/esphome/components/homeassistant/binary_sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await binary_sensor.new_binary_sensor(config) diff --git a/esphome/components/homeassistant/button/__init__.py b/esphome/components/homeassistant/button/__init__.py new file mode 100644 index 00000000000..f486a841263 --- /dev/null +++ b/esphome/components/homeassistant/button/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["api"] + +SUPPORTED_DOMAINS = [ + "button", + "input_button", +] + +HomeassistantButton = homeassistant_ns.class_( + "HomeassistantButton", button.Button, cg.Component +) + +CONFIG_SCHEMA = cv.All( + button.button_schema(HomeassistantButton) + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("button", SUPPORTED_DOMAINS), +) + +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + var = await button.new_button(config) + await cg.register_component(var, config) + setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/button/homeassistant_button.cpp b/esphome/components/homeassistant/button/homeassistant_button.cpp new file mode 100644 index 00000000000..87280c2b5b1 --- /dev/null +++ b/esphome/components/homeassistant/button/homeassistant_button.cpp @@ -0,0 +1,54 @@ +#include "homeassistant_button.h" + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +static const char *const TAG = "homeassistant.button"; + +void HomeassistantButton::dump_config() { + LOG_BUTTON("", "Homeassistant Button", this); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); +} + +float HomeassistantButton::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } + +void HomeassistantButton::press_action() { + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "No clients connected to API server"); + return; + } + +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + + static constexpr auto SERVICE_BUTTON = StringRef::from_lit("button.press"); + static constexpr auto SERVICE_INPUT_BUTTON = StringRef::from_lit("input_button.press"); + static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + static constexpr char INPUT_PREFIX[] = "input_"; + + api::HomeassistantActionRequest resp; + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = SERVICE_INPUT_BUTTON; + } else { + resp.service = SERVICE_BUTTON; + } + + resp.data.init(1); + auto &entity_id_kv = resp.data.emplace_back(); + entity_id_kv.key = ENTITY_ID_KEY; + entity_id_kv.value = StringRef(this->entity_id_); + + api::global_api_server->send_homeassistant_action(resp); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/button/homeassistant_button.h b/esphome/components/homeassistant/button/homeassistant_button.h new file mode 100644 index 00000000000..cc7bbddfefa --- /dev/null +++ b/esphome/components/homeassistant/button/homeassistant_button.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "esphome/core/component.h" + +namespace esphome::homeassistant { + +class HomeassistantButton final : public button::Button, public Component { + public: + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + + void dump_config() override; + float get_setup_priority() const override; + + protected: + void press_action() override; + + const char *entity_id_{nullptr}; +}; + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/number/__init__.py b/esphome/components/homeassistant/number/__init__.py index ab1389e13a3..6288db68805 100644 --- a/esphome/components/homeassistant/number/__init__.py +++ b/esphome/components/homeassistant/number/__init__.py @@ -5,23 +5,30 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, + validate_entity_domain, ) CODEOWNERS = ["@landonr"] DEPENDENCIES = ["api"] +SUPPORTED_DOMAINS = ["input_number", "number"] + HomeassistantNumber = homeassistant_ns.class_( "HomeassistantNumber", number.Number, cg.Component ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( number.number_schema(HomeassistantNumber) .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("number", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 965f91d202b..e8a679e91cf 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -1,5 +1,7 @@ #include "homeassistant_number.h" +#include + #include "esphome/components/api/api_pb2.h" #include "esphome/components/api/api_server.h" #include "esphome/core/log.h" @@ -78,14 +80,28 @@ void HomeassistantNumber::control(float value) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + this->publish_state(value); - static constexpr auto SERVICE_NAME = StringRef::from_lit("number.set_value"); + static constexpr auto NUMBER_SERVICE_NAME = StringRef::from_lit("number.set_value"); + static constexpr auto INPUT_NUMBER_SERVICE_NAME = StringRef::from_lit("input_number.set_value"); + static constexpr char INPUT_PREFIX[] = "input_"; static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); static constexpr auto VALUE_KEY = StringRef::from_lit("value"); api::HomeassistantActionRequest resp; - resp.service = SERVICE_NAME; + // input_number entities have their own set_value action + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = INPUT_NUMBER_SERVICE_NAME; + } else { + resp.service = NUMBER_SERVICE_NAME; + } resp.data.init(2); auto &entity_id = resp.data.emplace_back(); diff --git a/esphome/components/homeassistant/select/__init__.py b/esphome/components/homeassistant/select/__init__.py new file mode 100644 index 00000000000..e8ad0fc23b8 --- /dev/null +++ b/esphome/components/homeassistant/select/__init__.py @@ -0,0 +1,57 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["api"] + +CONF_MAX_OPTIONS = "max_options" +CONF_OPTIONS_BUFFER_SIZE = "options_buffer_size" + +SUPPORTED_DOMAINS = [ + "input_select", + "select", +] + +HomeassistantSelect = homeassistant_ns.class_( + "HomeassistantSelect", select.Select, cg.Component +) + +CONFIG_SCHEMA = cv.All( + select.select_schema(HomeassistantSelect) + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend( + { + cv.Optional(CONF_MAX_OPTIONS, default=16): cv.int_range(min=1, max=255), + cv.Optional(CONF_OPTIONS_BUFFER_SIZE, default=256): cv.int_range( + min=2, max=4096 + ), + } + ) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("select", SUPPORTED_DOMAINS), +) + +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + # Options come from Home Assistant at runtime, into storage reserved during setup + var = await select.new_select( + config, + config[CONF_MAX_OPTIONS], + config[CONF_OPTIONS_BUFFER_SIZE], + options=[], + ) + await cg.register_component(var, config) + setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/select/homeassistant_select.cpp b/esphome/components/homeassistant/select/homeassistant_select.cpp new file mode 100644 index 00000000000..d3401de7f0e --- /dev/null +++ b/esphome/components/homeassistant/select/homeassistant_select.cpp @@ -0,0 +1,265 @@ +#include "homeassistant_select.h" + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +static const char *const TAG = "homeassistant.select"; + +namespace { + +/// Emit the UTF-8 encoding of a code point. Rejects NUL, which would end the option early. +template bool emit_utf8(uint32_t code_point, C &on_char) { + if (code_point == 0 || code_point > 0x10FFFF) + return false; + if (code_point < 0x80) { + on_char(static_cast(code_point)); + } else if (code_point < 0x800) { + on_char(static_cast(0xC0 | (code_point >> 6))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else if (code_point < 0x10000) { + on_char(static_cast(0xE0 | (code_point >> 12))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } else { + on_char(static_cast(0xF0 | (code_point >> 18))); + on_char(static_cast(0x80 | ((code_point >> 12) & 0x3F))); + on_char(static_cast(0x80 | ((code_point >> 6) & 0x3F))); + on_char(static_cast(0x80 | (code_point & 0x3F))); + } + return true; +} + +/** Walk a list attribute as Home Assistant sends it: the Python repr of a list of strings, such as + * `['Low', "It's high", 'caf\xe9']`. + * + * Calls on_char for each decoded byte of an option and on_option at the end of each option. Returns false + * on malformed input; the callbacks may already have run by then. + */ +template bool parse_option_list(StringRef src, C &&on_char, O &&on_option) { + const char *pos = src.c_str(); + const char *const end = pos + src.size(); + auto skip_spaces = [&]() { + while (pos < end && *pos == ' ') + pos++; + }; + + skip_spaces(); + if (pos == end || *pos != '[') + return false; + pos++; + skip_spaces(); + if (pos < end && *pos == ']') { + pos++; + } else { + while (true) { + skip_spaces(); + if (pos == end || (*pos != '\'' && *pos != '"')) + return false; + const char quote = *pos++; + while (true) { + if (pos == end) + return false; + const char c = *pos++; + if (c == quote) + break; + if (c != '\\') { + on_char(c); + continue; + } + if (pos == end) + return false; + const char escape = *pos++; + switch (escape) { + case '\\': + case '\'': + case '"': + on_char(escape); + break; + case 'n': + on_char('\n'); + break; + case 'r': + on_char('\r'); + break; + case 't': + on_char('\t'); + break; + case 'x': + case 'u': + case 'U': { + const size_t digits = escape == 'x' ? 2 : (escape == 'u' ? 4 : 8); + if (static_cast(end - pos) < digits) + return false; + auto code_point = parse_hex(pos, digits); + if (!code_point.has_value() || !emit_utf8(*code_point, on_char)) + return false; + pos += digits; + break; + } + default: + return false; + } + } + on_option(); + skip_spaces(); + if (pos == end) + return false; + const char separator = *pos++; + if (separator == ']') + break; + if (separator != ',') + return false; + } + } + skip_spaces(); + return pos == end; +} + +} // namespace + +void HomeassistantSelect::setup() { + this->options_buffer_ = std::make_unique(this->options_buffer_size_); + this->option_list_ = std::make_unique(this->max_options_); + + // Subscribe to the options first: Home Assistant answers subscriptions in order, so the options are + // known by the time the first state arrives. + api::global_api_server->subscribe_home_assistant_state( + this->entity_id_, "options", [this](StringRef options) { this->options_changed_(options); }); + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, + [this](StringRef state) { this->state_changed_(state); }); +} + +void HomeassistantSelect::options_changed_(StringRef options) { + // First pass: validate and measure the new options, and look for the active option among them, before + // anything is overwritten. A list that does not fit leaves the current options in place. + const char *active = this->has_state() ? this->option_at(this->active_index_) : nullptr; + size_t count = 0; + size_t bytes = 0; + size_t match_len = 0; + bool matching = active != nullptr; + optional new_active; + bool valid = parse_option_list( + options, + [&](char c) { + bytes++; + if (matching && active[match_len] == c) { + match_len++; + } else { + matching = false; + } + }, + [&]() { + bytes++; // NUL terminator + if (matching && active[match_len] == '\0' && !new_active.has_value()) + new_active = count; + count++; + match_len = 0; + matching = active != nullptr; + }); + if (!valid) { + ESP_LOGW(TAG, "'%s': Can't parse options %s", this->entity_id_, options.c_str()); + return; + } + if (count > this->max_options_) { + ESP_LOGE(TAG, "'%s': %zu options exceed max_options (%u)", this->entity_id_, count, this->max_options_); + return; + } + if (bytes > this->options_buffer_size_) { + ESP_LOGE(TAG, "'%s': Options need %zu bytes, more than options_buffer_size (%u)", this->entity_id_, bytes, + this->options_buffer_size_); + return; + } + + // Second pass: the input is known to be valid and to fit, so write it out + char *buffer = this->options_buffer_.get(); + size_t offset = 0; + size_t option_start = 0; + size_t index = 0; + parse_option_list( + options, [&](char c) { buffer[offset++] = c; }, + [&]() { + buffer[offset++] = '\0'; + this->option_list_[index++] = buffer + option_start; + option_start = offset; + }); + // The traits only point at the list, which this select owns; set_options() would copy it to the heap + this->traits.set_options_static(this->option_list_.get(), count); + + // The active option may have moved to another index, or may be gone + if (new_active.has_value()) { + this->active_index_ = *new_active; + } else { + this->set_has_state(false); + } + ESP_LOGD(TAG, "'%s': Got %zu options", this->entity_id_, count); +} + +void HomeassistantSelect::state_changed_(StringRef state) { + auto index = this->index_of(state.c_str(), state.size()); + if (!index.has_value()) { + ESP_LOGW(TAG, "'%s': State '%s' is not one of the options", this->entity_id_, state.c_str()); + return; + } + if (this->has_state() && this->active_index_ == *index) + return; + ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_, state.c_str()); + this->publish_state(*index); +} + +void HomeassistantSelect::dump_config() { + LOG_SELECT("", "Homeassistant Select", this); + ESP_LOGCONFIG(TAG, + " Entity ID: '%s'\n" + " Max options: %u\n" + " Options buffer size: %u", + this->entity_id_, this->max_options_, this->options_buffer_size_); +} + +float HomeassistantSelect::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } + +void HomeassistantSelect::control(size_t index) { + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "No clients connected to API server"); + return; + } + +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + + static constexpr auto SERVICE_SELECT = StringRef::from_lit("select.select_option"); + static constexpr auto SERVICE_INPUT_SELECT = StringRef::from_lit("input_select.select_option"); + static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + static constexpr auto OPTION_KEY = StringRef::from_lit("option"); + static constexpr char INPUT_PREFIX[] = "input_"; + + api::HomeassistantActionRequest resp; + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = SERVICE_INPUT_SELECT; + } else { + resp.service = SERVICE_SELECT; + } + + resp.data.init(2); + auto &entity_id = resp.data.emplace_back(); + entity_id.key = ENTITY_ID_KEY; + entity_id.value = StringRef(this->entity_id_); + + auto &option = resp.data.emplace_back(); + option.key = OPTION_KEY; + option.value = StringRef(this->option_at(index)); + + api::global_api_server->send_homeassistant_action(resp); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/select/homeassistant_select.h b/esphome/components/homeassistant/select/homeassistant_select.h new file mode 100644 index 00000000000..843e07e60ef --- /dev/null +++ b/esphome/components/homeassistant/select/homeassistant_select.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +/** Mirrors a Home Assistant `select` or `input_select` entity. + * + * The options are read from the entity's `options` attribute at runtime. They are stored in a buffer of + * `options_buffer_size` bytes and a list of at most `max_options` entries, both reserved in setup(), so a + * change of options never allocates. + */ +class HomeassistantSelect final : public select::Select, public Component { + public: + HomeassistantSelect(uint8_t max_options, uint16_t options_buffer_size) + : options_buffer_size_(options_buffer_size), max_options_(max_options) {} + + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + protected: + void options_changed_(StringRef options); + void state_changed_(StringRef state); + + void control(size_t index) override; + + const char *entity_id_{nullptr}; + + private: + // The traits point at option_list_, whose entries point into options_buffer_; both are sized once in + // setup() and must match options_buffer_size_ and max_options_ + std::unique_ptr options_buffer_; + std::unique_ptr option_list_; + uint16_t options_buffer_size_; + uint8_t max_options_; +}; + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/sensor/__init__.py b/esphome/components/homeassistant/sensor/__init__.py index abee957fda3..1417c57bb54 100644 --- a/esphome/components/homeassistant/sensor/__init__.py +++ b/esphome/components/homeassistant/sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) diff --git a/esphome/components/homeassistant/switch/__init__.py b/esphome/components/homeassistant/switch/__init__.py index 55854cd6591..495779b6ed3 100644 --- a/esphome/components/homeassistant/switch/__init__.py +++ b/esphome/components/homeassistant/switch/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, validate_entity_domain, @@ -36,6 +37,8 @@ CONFIG_SCHEMA = cv.All( validate_entity_domain("switch", SUPPORTED_DOMAINS), ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: cg.add_define("USE_API_HOMEASSISTANT_SERVICES") diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 8a4ea19f2e5..79d75c91336 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -40,6 +40,13 @@ void HomeassistantSwitch::write_state(bool state) { return; } +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + static constexpr auto SERVICE_ON = StringRef::from_lit("homeassistant.turn_on"); static constexpr auto SERVICE_OFF = StringRef::from_lit("homeassistant.turn_off"); static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); diff --git a/esphome/components/homeassistant/text/__init__.py b/esphome/components/homeassistant/text/__init__.py new file mode 100644 index 00000000000..67377e02d05 --- /dev/null +++ b/esphome/components/homeassistant/text/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import text +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import ( + HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA, + final_validate_entity_id, + homeassistant_ns, + setup_home_assistant_entity, + validate_entity_domain, +) + +CODEOWNERS = ["@jesserockz"] +DEPENDENCIES = ["api"] + +SUPPORTED_DOMAINS = [ + "input_text", + "text", +] + +HomeassistantText = homeassistant_ns.class_( + "HomeassistantText", text.Text, cg.Component +) + +CONFIG_SCHEMA = cv.All( + text.text_schema(HomeassistantText, mode="TEXT") + .extend(HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + validate_entity_domain("text", SUPPORTED_DOMAINS), +) + +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_API_HOMEASSISTANT_SERVICES") + var = await text.new_text(config) + await cg.register_component(var, config) + setup_home_assistant_entity(var, config) diff --git a/esphome/components/homeassistant/text/homeassistant_text.cpp b/esphome/components/homeassistant/text/homeassistant_text.cpp new file mode 100644 index 00000000000..31e4919700f --- /dev/null +++ b/esphome/components/homeassistant/text/homeassistant_text.cpp @@ -0,0 +1,114 @@ +#include "homeassistant_text.h" + +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +static const char *const TAG = "homeassistant.text"; + +void HomeassistantText::state_changed_(StringRef state) { + if (state == this->state) { + return; + } + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, state.c_str()); + this->publish_state(state.c_str(), state.size()); +} + +void HomeassistantText::min_retrieved_(StringRef min) { + auto min_value = parse_number(min.c_str()); + if (!min_value.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Min retrieved: %s", this->entity_id_, min.c_str()); + this->traits.set_min_length(min_value.value()); +} + +void HomeassistantText::max_retrieved_(StringRef max) { + auto max_value = parse_number(max.c_str()); + if (!max_value.has_value()) { + ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Max retrieved: %s", this->entity_id_, max.c_str()); + this->traits.set_max_length(max_value.value()); +} + +void HomeassistantText::mode_retrieved_(StringRef mode) { + if (mode == "text") { + this->traits.set_mode(text::TEXT_MODE_TEXT); + } else if (mode == "password") { + this->traits.set_mode(text::TEXT_MODE_PASSWORD); + } else { + ESP_LOGW(TAG, "'%s': Unknown 'mode' value '%s'", this->entity_id_, mode.c_str()); + return; + } + ESP_LOGD(TAG, "'%s': Mode retrieved: %s", this->entity_id_, mode.c_str()); +} + +void HomeassistantText::setup() { + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, + [this](StringRef state) { this->state_changed_(state); }); + + api::global_api_server->get_home_assistant_state(this->entity_id_, "min", + [this](StringRef min) { this->min_retrieved_(min); }); + api::global_api_server->get_home_assistant_state(this->entity_id_, "max", + [this](StringRef max) { this->max_retrieved_(max); }); + api::global_api_server->get_home_assistant_state(this->entity_id_, "mode", + [this](StringRef mode) { this->mode_retrieved_(mode); }); + // The "pattern" attribute is not fetched: Home Assistant sends "None" when it is unset, + // and a runtime pattern would need its own buffer. +} + +void HomeassistantText::dump_config() { + LOG_TEXT("", "Homeassistant Text", this); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); +} + +float HomeassistantText::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } + +void HomeassistantText::control(const std::string &value) { + if (!api::global_api_server->is_connected()) { + ESP_LOGE(TAG, "No clients connected to API server"); + return; + } + +#ifdef USE_API_WIZARD_LINKED_INPUTS + if (this->entity_id_[0] == '\0') { + ESP_LOGW(TAG, "'%s': No entity ID set yet", this->get_name().c_str()); + return; + } +#endif + + static constexpr auto SERVICE_TEXT = StringRef::from_lit("text.set_value"); + static constexpr auto SERVICE_INPUT_TEXT = StringRef::from_lit("input_text.set_value"); + static constexpr auto ENTITY_ID_KEY = StringRef::from_lit("entity_id"); + static constexpr auto VALUE_KEY = StringRef::from_lit("value"); + static constexpr char INPUT_PREFIX[] = "input_"; + + api::HomeassistantActionRequest resp; + if (strncmp(this->entity_id_, INPUT_PREFIX, sizeof(INPUT_PREFIX) - 1) == 0) { + resp.service = SERVICE_INPUT_TEXT; + } else { + resp.service = SERVICE_TEXT; + } + + resp.data.init(2); + auto &entity_id_kv = resp.data.emplace_back(); + entity_id_kv.key = ENTITY_ID_KEY; + entity_id_kv.value = StringRef(this->entity_id_); + + auto &value_kv = resp.data.emplace_back(); + value_kv.key = VALUE_KEY; + value_kv.value = StringRef(value.data(), value.size()); + + api::global_api_server->send_homeassistant_action(resp); +} + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/text/homeassistant_text.h b/esphome/components/homeassistant/text/homeassistant_text.h new file mode 100644 index 00000000000..978dfdae01e --- /dev/null +++ b/esphome/components/homeassistant/text/homeassistant_text.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/text/text.h" +#include "esphome/core/component.h" +#include "esphome/core/string_ref.h" + +namespace esphome::homeassistant { + +class HomeassistantText final : public text::Text, public Component { + public: + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + + void setup() override; + void dump_config() override; + float get_setup_priority() const override; + + protected: + void state_changed_(StringRef state); + void min_retrieved_(StringRef min); + void max_retrieved_(StringRef max); + void mode_retrieved_(StringRef mode); + + void control(const std::string &value) override; + + const char *entity_id_{nullptr}; +}; + +} // namespace esphome::homeassistant diff --git a/esphome/components/homeassistant/text_sensor/__init__.py b/esphome/components/homeassistant/text_sensor/__init__.py index 265250c6959..f57b398111e 100644 --- a/esphome/components/homeassistant/text_sensor/__init__.py +++ b/esphome/components/homeassistant/text_sensor/__init__.py @@ -4,6 +4,7 @@ from esphome.types import ConfigType from .. import ( HOME_ASSISTANT_IMPORT_SCHEMA, + final_validate_entity_id, homeassistant_ns, setup_home_assistant_entity, ) @@ -18,6 +19,8 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend( HOME_ASSISTANT_IMPORT_SCHEMA ) +FINAL_VALIDATE_SCHEMA = final_validate_entity_id + async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) diff --git a/esphome/components/honeywell_hih_i2c/sensor.py b/esphome/components/honeywell_hih_i2c/sensor.py index 5250e1c1c79..8ab2cd16b8b 100644 --- a/esphome/components/honeywell_hih_i2c/sensor.py +++ b/esphome/components/honeywell_hih_i2c/sensor.py @@ -48,10 +48,6 @@ 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)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/components/honeywellabp/sensor.py b/esphome/components/honeywellabp/sensor.py index 4b116f0f161..d7e7cf29a4e 100644 --- a/esphome/components/honeywellabp/sensor.py +++ b/esphome/components/honeywellabp/sensor.py @@ -56,14 +56,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await spi.register_spi_device(var, config) - if CONF_PRESSURE in config: + sensors = sensor.sub_sensors(config) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): conf = config[CONF_PRESSURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_pressure_sensor(sens)) cg.add(var.set_honeywellabp_min_pressure(conf[CONF_MIN_PRESSURE])) cg.add(var.set_honeywellabp_max_pressure(conf[CONF_MAX_PRESSURE])) - - if CONF_TEMPERATURE in config: - conf = config[CONF_TEMPERATURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_temperature_sensor(sens)) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/honeywellabp2_i2c/sensor.py b/esphome/components/honeywellabp2_i2c/sensor.py index 299acd4b527..d4c4e44a3b6 100644 --- a/esphome/components/honeywellabp2_i2c/sensor.py +++ b/esphome/components/honeywellabp2_i2c/sensor.py @@ -63,13 +63,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + pressure_config = config[CONF_PRESSURE] cg.add(var.set_min_pressure(pressure_config[CONF_MIN_PRESSURE])) cg.add(var.set_max_pressure(pressure_config[CONF_MAX_PRESSURE])) cg.add(var.set_transfer_function(pressure_config[TRANSFER_FUNCTION])) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) 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/host/core.cpp b/esphome/components/host/core.cpp index 9292cd77f61..f753534efd9 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -90,6 +90,8 @@ int main(int argc, char **argv) { // Install signal handlers for graceful shutdown (flushes preferences to disk) std::signal(SIGINT, signal_handler); std::signal(SIGTERM, signal_handler); + // A write to a closed peer must fail with EPIPE, as on the embedded stacks, not kill the process + std::signal(SIGPIPE, SIG_IGN); esphome::host::setup_preferences(); setup(); diff --git a/esphome/components/hte501/sensor.py b/esphome/components/hte501/sensor.py index bf9fe4000ee..80d59862959 100644 --- a/esphome/components/hte501/sensor.py +++ b/esphome/components/hte501/sensor.py @@ -50,10 +50,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature_sensor(sens)) - - if humidity := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index de35d52a409..5faf9896868 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -202,11 +202,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_watchdog_timeout(timeout_ms)) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time). - # esp-tls is re-enabled too because http_request includes - # directly and esp_http_client only pulls it in as a private dependency. - esp32.include_builtin_idf_component("esp_http_client") - esp32.include_builtin_idf_component("esp-tls") + esp32.request_http_client() cg.add(var.set_buffer_size_rx(config[CONF_BUFFER_SIZE_RX])) cg.add(var.set_buffer_size_tx(config[CONF_BUFFER_SIZE_TX])) diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index d45208ed5df..37e0de5d6a0 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -25,11 +25,10 @@ std::string HttpContainer::get_response_header(const std::string &header_name) { auto lower = str_lower_case(header_name); // NOLINT for (const auto &entry : this->response_headers_) { if (entry.name == lower) { - ESP_LOGD(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); + ESP_LOGV(TAG, "Header with name %s found with value %s", lower.c_str(), entry.value.c_str()); return entry.value; } } - ESP_LOGW(TAG, "No header with name %s found", lower.c_str()); return ""; } diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 71668b8556f..68696118ea8 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -268,6 +268,11 @@ class HttpContainer : public Parented { return !this->is_chunked_ && this->bytes_read_ >= this->content_length; } + /** + * @brief Get the value of a response header by name. + * @param header_name The name of the header to retrieve. + * @return The value of the header if found, otherwise an empty string. + */ std::string get_response_header(const std::string &header_name); protected: 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/htu31d/sensor.py b/esphome/components/htu31d/sensor.py index 8960759d9bf..41cc54686a4 100644 --- a/esphome/components/htu31d/sensor.py +++ b/esphome/components/htu31d/sensor.py @@ -48,10 +48,6 @@ 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(sens)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) 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_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/hyt271/sensor.py b/esphome/components/hyt271/sensor.py index 3f006a65fe0..1fe5d9e14aa 100644 --- a/esphome/components/hyt271/sensor.py +++ b/esphome/components/hyt271/sensor.py @@ -48,10 +48,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature(sens)) - - if humidity := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity) - cg.add(var.set_humidity(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index 37895ac4e79..1b17380ed87 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -60,11 +60,11 @@ class I2SAudioMicrophone final : public I2SAudioIn, public microphone::Microphon gpio_num_t din_pin_{I2S_GPIO_UNUSED}; i2s_chan_handle_t rx_handle_; - bool pdm_{false}; #if SOC_I2S_SUPPORTS_PDM_RX i2s_pdm_dsr_t pdm_dsr_{I2S_PDM_DSR_8S}; #endif + bool pdm_{false}; bool correct_dc_offset_; bool locked_driver_{false}; int32_t dc_offset_prev_input_{0}; diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index cb82b09f33a..4b74b2e29d4 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -40,6 +40,16 @@ void I2SAudioSpeakerBase::setup() { // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); this->set_mute_state(this->mute_state_); + + // Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG + // function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now. + this->park_dout_pin_(); +} + +void I2SAudioSpeakerBase::park_dout_pin_() { + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } void I2SAudioSpeakerBase::dump_config() { @@ -77,6 +87,12 @@ void I2SAudioSpeakerBase::loop() { ESP_LOGV(TAG, "Started"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); this->state_ = speaker::STATE_RUNNING; +#ifdef USE_AUDIO_DAC + // The task raises TASK_RUNNING only after enabling the channel, so the clocks are already running + if (this->audio_dac_ != nullptr) { + this->audio_dac_->on_audio_started(); + } +#endif // USE_AUDIO_DAC } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { ESP_LOGV(TAG, "Stopping"); @@ -297,9 +313,7 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - gpio_reset_pin(this->dout_pin_); - gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); - gpio_set_level(this->dout_pin_, 0); + this->park_dout_pin_(); } this->parent_->unlock(); } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index b443166ea1b..965eede0201 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -129,6 +129,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Stops the I2S driver and unlocks the I2S port void stop_i2s_driver_(); + /// @brief Detaches dout from the I2S signal and drives it low + void park_dout_pin_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} diff --git a/esphome/components/iaqcore/sensor.py b/esphome/components/iaqcore/sensor.py index 1b905e4c63d..daee46f0838 100644 --- a/esphome/components/iaqcore/sensor.py +++ b/esphome/components/iaqcore/sensor.py @@ -47,12 +47,8 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if co2_config := config.get(CONF_CO2): - sens = await sensor.new_sensor(co2_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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2) + await sensors(CONF_TVOC, var.set_tvoc) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/improv_ble/improv_ble_component.h b/esphome/components/improv_ble/improv_ble_component.h index 2552bed69b5..126663ed000 100644 --- a/esphome/components/improv_ble/improv_ble_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -72,9 +72,6 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas improv::Error get_improv_error_state() const { return this->error_state_; } protected: - bool should_start_{false}; - bool setup_complete_{false}; - uint32_t identify_start_{0}; uint32_t identify_duration_; uint32_t authorized_start_{0}; @@ -84,6 +81,14 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas std::vector incoming_data_; wifi::WiFiAP connecting_sta_; + // Single byte fields here fill the alignment gap between connecting_sta_ and the next pointer + improv::State state_{improv::STATE_STOPPED}; + improv::Error error_state_{improv::ERROR_NONE}; + bool should_start_{false}; + bool setup_complete_{false}; + bool status_indicator_state_{false}; + bool advertising_device_name_{false}; + bool advertising_requested_{false}; BLEService *service_{nullptr}; BLECharacteristic *status_{nullptr}; @@ -99,16 +104,11 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas output::BinaryOutput *status_indicator_{nullptr}; #endif - improv::State state_{improv::STATE_STOPPED}; - improv::Error error_state_{improv::ERROR_NONE}; #ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif - bool status_indicator_state_{false}; uint32_t last_name_adv_time_{0}; - bool advertising_device_name_{false}; - bool advertising_requested_{false}; void set_status_indicator_state_(bool state); void update_advertising_type_(); void request_advertising_(); 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/ina260/sensor.py b/esphome/components/ina260/sensor.py index b7b94a248b4..20cf1fee45f 100644 --- a/esphome/components/ina260/sensor.py +++ b/esphome/components/ina260/sensor.py @@ -59,14 +59,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - 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_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_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/__init__.py b/esphome/components/infrared/__init__.py index d04c82ea965..47ca5bf07fb 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -9,20 +9,21 @@ Once the API is considered stable, this warning will be removed. """ import esphome.codegen as cg +from esphome.components import ir_rf_base import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import queue_entity_register, setup_entity +from esphome.core import coroutine_with_priority +from esphome.core.entity_helpers import setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["remote_base"] +AUTO_LOAD = ["ir_rf_base"] IS_PLATFORM_COMPONENT = True infrared_ns = cg.esphome_ns.namespace("infrared") -Infrared = infrared_ns.class_("Infrared", cg.EntityBase, cg.Component) +Infrared = infrared_ns.class_("Infrared", ir_rf_base.IrRfEntity) InfraredCall = infrared_ns.class_("InfraredCall") InfraredTraits = infrared_ns.class_("InfraredTraits") @@ -52,11 +53,8 @@ async def setup_infrared_core_(var: cg.MockObj, config: ConfigType) -> None: async def register_infrared(var: cg.MockObj, config: ConfigType) -> None: """Register an infrared device with the core.""" - cg.add_define("USE_IR_RF") - await cg.register_component(var, config) - queue_entity_register("infrared", config) + await ir_rf_base.register_ir_rf_entity(var, config, "infrared") await setup_infrared_core_(var, config) - CORE.register_platform_component("infrared", var) async def new_infrared(config: ConfigType, *args: SafeExpType) -> cg.MockObj: diff --git a/esphome/components/infrared/infrared.cpp b/esphome/components/infrared/infrared.cpp index 83039a5a9bf..67a40fa3490 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -1,161 +1,18 @@ #include "infrared.h" -#include - #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - namespace esphome::infrared { static const char *const TAG = "infrared"; -// ========== InfraredCall ========== - -InfraredCall &InfraredCall::set_carrier_frequency(uint32_t frequency) { - this->carrier_frequency_ = frequency; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings(const std::vector &timings) { - this->raw_timings_ = &timings; - this->packed_data_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings_base64url(const std::string &base64url) { - this->base64url_ptr_ = &base64url; - this->raw_timings_ = nullptr; - this->packed_data_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { - this->packed_data_ = data; - this->packed_length_ = length; - this->packed_count_ = count; - this->raw_timings_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_repeat_count(uint32_t count) { - this->repeat_count_ = count; - return *this; -} - -void InfraredCall::perform() { - if (this->parent_ != nullptr) { - this->parent_->control(*this); - } -} - -// ========== Infrared ========== - -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()); -} - void Infrared::dump_config() { ESP_LOGCONFIG(TAG, "Infrared '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); -} - -void Infrared::control(const InfraredCall &call) { - if (this->transmitter_ == nullptr) { - ESP_LOGW(TAG, "No transmitter configured"); - return; - } - - if (!call.has_raw_timings()) { - ESP_LOGE(TAG, "No raw timings provided"); - return; - } - - // Create transmit data object - auto transmit_call = this->transmitter_->transmit(); - auto *transmit_data = transmit_call.get_data(); - - // Set carrier frequency - auto freq = call.get_carrier_frequency(); - if (freq.has_value()) { - transmit_data->set_carrier_frequency(*freq); - } - - // Set timings based on format - if (call.is_packed()) { - // Zero-copy from packed protobuf data - transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), - call.get_packed_count()); - ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), - call.get_repeat_count()); - } else if (call.is_base64url()) { - // Decode base64url (URL-safe) into transmit buffer - if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { - ESP_LOGE(TAG, "Invalid base64url data"); - return; - } - // Sanity check: validate timing values are within reasonable bounds - constexpr int32_t max_timing_us = 500000; // 500ms absolute max - for (int32_t timing : transmit_data->get_data()) { - int32_t abs_timing = timing < 0 ? -timing : timing; - if (abs_timing > max_timing_us) { - ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); - return; - } - } - ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), - call.get_repeat_count()); - } else { - // From vector (lambdas/automations) - transmit_data->set_data(call.get_raw_timings()); - ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), - call.get_repeat_count()); - } - - // Set repeat count - if (call.get_repeat_count() > 0) { - transmit_call.set_send_times(call.get_repeat_count()); - } - - // Perform transmission - transmit_call.perform(); -} - -uint32_t Infrared::get_capability_flags() const { - uint32_t flags = 0; - - // Add transmit/receive capability based on traits - if (this->traits_.get_supports_transmitter()) - flags |= InfraredCapability::CAPABILITY_TRANSMITTER; - if (this->traits_.get_supports_receiver()) - flags |= InfraredCapability::CAPABILITY_RECEIVER; - - return flags; -} - -bool Infrared::on_receive(remote_base::RemoteReceiveData data) { - // Forward received IR data to API server -#if defined(USE_API) && defined(USE_IR_RF) - if (api::global_api_server != nullptr) { -#ifdef USE_DEVICES - uint32_t device_id = this->get_device_id(); -#else - uint32_t device_id = 0; -#endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); - } -#endif - return false; // Don't consume the event, allow other listeners to process it + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); } } // namespace esphome::infrared diff --git a/esphome/components/infrared/infrared.h b/esphome/components/infrared/infrared.h index afbde57be2f..d81e3e2e38d 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -4,131 +4,48 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. -#include "esphome/core/component.h" -#include "esphome/core/entity_base.h" -#include "esphome/components/remote_base/remote_base.h" - -#include +#include "esphome/components/ir_rf_base/ir_rf_base.h" namespace esphome::infrared { -/// Capability flags for individual infrared instances -enum InfraredCapability : uint32_t { - CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals - CAPABILITY_RECEIVER = 1 << 1, // Can receive signals -}; +using ir_rf_base::CAPABILITY_RECEIVER; +using ir_rf_base::CAPABILITY_TRANSMITTER; -/// Forward declarations class Infrared; /// InfraredCall - Builder pattern for transmitting infrared signals -class InfraredCall { +class InfraredCall : public ir_rf_base::IrRfCall { public: - explicit InfraredCall(Infrared *parent) : parent_(parent) {} + explicit InfraredCall(Infrared *parent) : IrRfCall(parent) {} /// Set the carrier frequency in Hz - InfraredCall &set_carrier_frequency(uint32_t frequency); - - // ===== Raw Timings Methods ===== - // All set_raw_timings_* methods store pointers/references to external data. - // The referenced data must remain valid until perform() completes. - // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous - // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! - - /// Set the raw timings from a vector (positive = mark, negative = space) - /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). - /// @note Usage: Primarily for lambdas/automations where the vector is in scope. - InfraredCall &set_raw_timings(const std::vector &timings); - - /// Set the raw timings from base64url-encoded little-endian int32 data - /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). - /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). - /// @note Decoding happens at perform() time, directly into the transmit buffer. - InfraredCall &set_raw_timings_base64url(const std::string &base64url); - - /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) - /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). - /// @note Usage: For API component where data comes directly from the protobuf message. - InfraredCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); - - /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) - InfraredCall &set_repeat_count(uint32_t count); - - /// Perform the transmission - void perform(); - + InfraredCall &set_carrier_frequency(uint32_t frequency) { + this->carrier_frequency_ = frequency; + return *this; + } /// Get the carrier frequency const optional &get_carrier_frequency() const { return this->carrier_frequency_; } - /// Get the raw timings (only valid if set via set_raw_timings) - const std::vector &get_raw_timings() const { return *this->raw_timings_; } - /// Check if raw timings have been set (any format) - bool has_raw_timings() const { - return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; - } - /// Check if using packed data format - bool is_packed() const { return this->packed_data_ != nullptr; } - /// Check if using base64url data format - bool is_base64url() const { return this->base64url_ptr_ != nullptr; } - /// Get the base64url data string - const std::string &get_base64url_data() const { return *this->base64url_ptr_; } - /// Get packed data (only valid if set via set_raw_timings_packed) - const uint8_t *get_packed_data() const { return this->packed_data_; } - uint16_t get_packed_length() const { return this->packed_length_; } - uint16_t get_packed_count() const { return this->packed_count_; } - /// Get the repeat count - uint32_t get_repeat_count() const { return this->repeat_count_; } protected: - uint32_t repeat_count_{1}; - Infrared *parent_; optional carrier_frequency_; - // Pointer to vector-based timings (caller-owned, must outlive perform()) - const std::vector *raw_timings_{nullptr}; - // Pointer to base64url-encoded string (caller-owned, must outlive perform()) - const std::string *base64url_ptr_{nullptr}; - // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) - const uint8_t *packed_data_{nullptr}; - uint16_t packed_length_{0}; - uint16_t packed_count_{0}; }; /// InfraredTraits - Describes the capabilities of an infrared implementation class InfraredTraits { public: - bool get_supports_transmitter() const { return this->supports_transmitter_; } - void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } - - bool get_supports_receiver() const { return this->supports_receiver_; } - void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } - uint32_t get_receiver_frequency_hz() const { return this->receiver_frequency_hz_; } void set_receiver_frequency_hz(uint32_t freq) { this->receiver_frequency_hz_ = freq; } protected: - bool supports_transmitter_{false}; - bool supports_receiver_{false}; uint32_t receiver_frequency_hz_{0}; // Demodulation frequency of the IR receiver in Hz (0 = unspecified) }; /// Infrared - Base class for infrared remote control implementations -class Infrared : public Component, public EntityBase, public remote_base::RemoteReceiverListener { +class Infrared : public ir_rf_base::IrRfEntity { public: Infrared() = default; - void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } - - /// 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; } - - /// Check if this infrared has a transmitter configured - bool has_transmitter() const { return this->transmitter_ != nullptr; } - /// Check if this infrared has a receiver configured - bool has_receiver() const { return this->receiver_ != nullptr; } /// Get the traits for this infrared implementation InfraredTraits &get_traits() { return this->traits_; } @@ -137,21 +54,16 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote /// Create a call object for transmitting InfraredCall make_call() { return InfraredCall(this); } - /// Get capability flags for this infrared instance - uint32_t get_capability_flags() const; - - /// Called when IR data is received (from RemoteReceiverListener) - bool on_receive(remote_base::RemoteReceiveData data) override; - protected: - friend class InfraredCall; + friend class ir_rf_base::IrRfCall; - /// Perform the actual transmission (called by InfraredCall) - virtual void control(const InfraredCall &call); - - // Underlying hardware components - remote_base::RemoteReceiverBase *receiver_{nullptr}; - remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + void on_call_(const InfraredCall &) {} + /// Perform the actual transmission (called by InfraredCall); false only when no frame was handed + /// to the transmitter, in which case no completion follows + /// Without a remote_base transmitter, call api_transmit_done_() once the frame is out + virtual bool control(const InfraredCall &call) { + return this->transmit_raw_(call, call.get_carrier_frequency().value_or(0)); + } // Traits describing capabilities InfraredTraits traits_; 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/ir_rf_base/__init__.py b/esphome/components/ir_rf_base/__init__.py new file mode 100644 index 00000000000..55536c131b1 --- /dev/null +++ b/esphome/components/ir_rf_base/__init__.py @@ -0,0 +1,38 @@ +"""Shared base for the infrared and radio_frequency entity components.""" + +import esphome.codegen as cg +from esphome.components import remote_base +from esphome.const import CONF_API +from esphome.core import CORE +from esphome.core.entity_helpers import queue_entity_register +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81", "@bdraco"] +AUTO_LOAD = ["remote_base"] + +ir_rf_base_ns = cg.esphome_ns.namespace("ir_rf_base") +IrRfEntity = ir_rf_base_ns.class_("IrRfEntity", cg.EntityBase, cg.Component) + + +async def attach_transmitter(var: cg.MockObj, config: ConfigType, key: str) -> None: + """Link the configured transmitter to an entity. + + With the API configured this also compiles in the transmit completion + tracking that answers API transmit requests; the transmitter platform has + to report completion through notify_complete_(), as remote_transmitter + does. Without this call anywhere in the build a request is answered as + soon as the frame is handed over. + """ + await remote_base.register_transmittable(var, config, key) + if CONF_API in CORE.config: + cg.add_define("USE_IR_RF_TRANSMIT_COMPLETE") + + +async def register_ir_rf_entity( + var: cg.MockObj, config: ConfigType, domain: str +) -> None: + """Register an infrared or radio_frequency entity; USE_IR_RF covers both kinds.""" + cg.add_define("USE_IR_RF") + await cg.register_component(var, config) + queue_entity_register(domain, config) + CORE.register_platform_component(domain, var) diff --git a/esphome/components/ir_rf_base/ir_rf_base.cpp b/esphome/components/ir_rf_base/ir_rf_base.cpp new file mode 100644 index 00000000000..9aa845f269e --- /dev/null +++ b/esphome/components/ir_rf_base/ir_rf_base.cpp @@ -0,0 +1,237 @@ +#include "ir_rf_base.h" + +#include +#include + +#include "esphome/core/log.h" + +#ifdef USE_API +#include "esphome/components/api/api_connection.h" +#include "esphome/components/api/api_server.h" +#include "esphome/core/application.h" +#endif + +namespace esphome::ir_rf_base { + +static const char *const TAG = "ir_rf"; + +#if defined(USE_API) && defined(USE_IR_RF) +// A missing completion is answered as failed this long after the frame should have left the +// wire, and an owed reply the client never reads is dropped this long after the first refusal +static constexpr uint32_t API_REPLY_TIMEOUT_MS = 30000; +#endif +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +// Longest air time added to the deadline, in 16 ms ticks (8 min); with the 30 s above the +// deadline stays within the signed 16 bit tick window loop() compares against +static constexpr uint16_t API_REPLY_MAX_AIR_TICKS = 30000; +#endif + +#if defined(USE_API) && defined(USE_IR_RF) +static uint16_t api_reply_ticks_from_now() { + return static_cast((App.get_loop_component_start_time() >> 4) + (API_REPLY_TIMEOUT_MS >> 4)); +} +#endif + +void IrRfEntity::setup() { + // merged, not assigned: a platform may have set a flag for its own hardware before setup() + if (this->has_transmitter()) + this->supports_transmitter_ = true; + if (this->has_receiver()) + this->supports_receiver_ = true; +} + +bool IrRfEntity::transmit_raw_(const IrRfCallData &call, uint32_t carrier_frequency_hz) { + if (this->transmitter_ == nullptr) { + ESP_LOGW(TAG, "No transmitter configured"); + return false; + } + + if (!call.has_raw_timings()) { + ESP_LOGE(TAG, "No raw timings provided"); + return false; + } + + auto transmit_call = this->transmitter_->transmit(); + auto *transmit_data = transmit_call.get_data(); + transmit_data->set_carrier_frequency(carrier_frequency_hz); + + if (call.is_packed()) { + // Zero-copy from packed protobuf data + transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), + call.get_packed_count()); + ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), + call.get_repeat_count()); + } else if (call.is_base64url()) { + // Decode base64url (URL-safe) into transmit buffer + if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { + ESP_LOGE(TAG, "Invalid base64url data"); + return false; + } + constexpr int32_t max_timing_us = 500000; // 500ms absolute max + for (int32_t timing : transmit_data->get_data()) { + int32_t abs_timing = timing < 0 ? -timing : timing; + if (abs_timing > max_timing_us) { + ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); + return false; + } + } + ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), + call.get_repeat_count()); + } else { + // From vector (lambdas/automations) + transmit_data->set_data(call.get_raw_timings()); + ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), + call.get_repeat_count()); + } + + // one answer for every backend: a frame that decoded to nothing is refused here + if (transmit_data->get_data().empty()) { + ESP_LOGE(TAG, "No raw timings provided"); + return false; + } + + if (call.get_repeat_count() > 0) { + transmit_call.set_send_times(call.get_repeat_count()); + } + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + // only the API frame expect_api_reply_() armed claims the seq and extends its deadline + if (call.wants_api_reply() && this->api_reply_ == ApiReply::API_REPLY_WAITING) { + this->inflight_seq_ = transmit_call.get_seq(); + // a long frame must not be answered as failed while still on the wire: the 30 s safety net + // starts after this frame's own air time (capped so the tick comparison cannot wrap) + uint64_t frame_us = 0; + for (const int64_t timing : transmit_data->get_data()) + frame_us += timing < 0 ? -timing : timing; + const uint64_t air_ticks = (frame_us * std::max(call.get_repeat_count(), 1) / 1000) >> 4; + this->api_reply_deadline_ += static_cast(std::min(air_ticks, API_REPLY_MAX_AIR_TICKS)); + } +#endif + transmit_call.perform(); + return true; +} + +bool IrRfEntity::on_receive(remote_base::RemoteReceiveData data) { +#if defined(USE_API) && defined(USE_IR_RF) + if (api::global_api_server != nullptr) { +#ifdef USE_DEVICES + uint32_t device_id = this->get_device_id(); +#else + uint32_t device_id = 0; +#endif + api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); + } +#endif + return false; // Don't consume the event, allow other listeners to process it +} + +#if defined(USE_API) && defined(USE_IR_RF) + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +void IrRfEntity::on_transmit_complete(remote_base::RemoteTransmitterBase *transmitter, uint16_t seq, bool sent) { + // only the frame this entity submitted; YAML automations and other entities share the transmitter + if (transmitter == this->transmitter_ && seq == this->inflight_seq_ && + this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(sent); +} +#endif + +bool IrRfEntity::expect_api_reply_(api::APIConnection *conn) { + // only an unpaced client gets here: its earlier request is answered as not started, and a reply + // the buffer still owes gets one last try; if that fails too the slot stays with it and the + // new request is refused rather than answered to nobody + if (this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(false); + if (this->api_reply_ != ApiReply::API_REPLY_NONE && !this->send_api_reply_()) { + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("refused, reply still owed")); + this->refuse_api_call_(conn); + return false; + } + this->api_reply_connection_ = conn; + this->api_reply_deadline_ = api_reply_ticks_from_now(); + this->api_reply_ = ApiReply::API_REPLY_WAITING; + return true; +} + +void IrRfEntity::refuse_api_call_(api::APIConnection *conn) { + uint32_t device_id = 0; +#ifdef USE_DEVICES + device_id = this->get_device_id(); +#endif + // no slot to retry from; a drop here is covered by the client's own timeout + if (!conn->send_infrared_rf_transmit_complete(device_id, this->get_object_id_hash(), false)) { + API_LOG_MSG_DROPPED(TAG, "IR/RF reply"); + } +} + +void IrRfEntity::finish_api_reply_(bool success) { + this->api_reply_ = success ? ApiReply::API_REPLY_OWED_OK : ApiReply::API_REPLY_OWED_FAILED; + if (!this->send_api_reply_()) { + // the retry gets its own window + this->api_reply_deadline_ = api_reply_ticks_from_now(); + } +} + +bool IrRfEntity::send_api_reply_() { + uint32_t device_id = 0; +#ifdef USE_DEVICES + device_id = this->get_device_id(); +#endif + // Refused by a full TCP buffer: the reply stays owed and loop() retries it, since a lost + // reply would stall the client's pacing for good (same shape as bluetooth_proxy) + if (!this->api_reply_connection_->send_infrared_rf_transmit_complete(device_id, this->get_object_id_hash(), + this->api_reply_ == ApiReply::API_REPLY_OWED_OK)) + return false; + this->clear_api_reply_(); + return true; +} + +// Only runs while an API reply is pending: retries an owed one until it goes out or the client is +// gone, and expires a transmit that never reported (a platform overriding control() without wiring +// its transmitter's completion) +void IrRfEntity::loop() { + if (this->api_reply_ == ApiReply::API_REPLY_NONE) { + this->disable_loop(); + return; + } + const auto remaining = static_cast(this->api_reply_deadline_ - (App.get_loop_component_start_time() >> 4)); + if (this->api_reply_ != ApiReply::API_REPLY_WAITING) { + if (this->send_api_reply_() || remaining > 0) + return; + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("reply dropped, client not reading")); + this->clear_api_reply_(); + return; + } + if (remaining > 0) + return; + ESP_LOGW(TAG, "'%s': transmit %s", this->get_name().c_str(), LOG_STR_LITERAL("never reported completion")); + this->finish_api_reply_(false); +} + +void IrRfEntity::on_api_connection_closed(api::APIConnection *conn) { + if (this->api_reply_connection_ == conn) { + this->clear_api_reply_(); + } +} +#endif + +} // namespace esphome::ir_rf_base + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +namespace esphome::remote_base { + +void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent) { +#ifdef USE_INFRARED + for (auto *entity : App.get_infrareds()) { + entity->on_transmit_complete(transmitter, seq, sent); + } +#endif +#ifdef USE_RADIO_FREQUENCY + for (auto *entity : App.get_radio_frequencies()) { + entity->on_transmit_complete(transmitter, seq, sent); + } +#endif +} + +} // namespace esphome::remote_base +#endif diff --git a/esphome/components/ir_rf_base/ir_rf_base.h b/esphome/components/ir_rf_base/ir_rf_base.h new file mode 100644 index 00000000000..c6dc8776968 --- /dev/null +++ b/esphome/components/ir_rf_base/ir_rf_base.h @@ -0,0 +1,273 @@ +#pragma once + +// WARNING: This component is EXPERIMENTAL. The API may change at any time +// without following the normal breaking changes policy. Use at your own risk. +// Once the API is considered stable, this warning will be removed. + +#include "esphome/core/component.h" +#include "esphome/core/entity_base.h" +#include "esphome/core/helpers.h" +#include "esphome/components/remote_base/remote_base.h" + +#include +#include + +#if defined(USE_API) && defined(USE_IR_RF) +namespace esphome::api { +class APIConnection; +} // namespace esphome::api +#endif + +namespace esphome::ir_rf_base { + +/// Capability flags reported by infrared and radio frequency entities +enum IrRfCapability : uint32_t { + CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals + CAPABILITY_RECEIVER = 1 << 1, // Can receive signals +}; + +/// Raw timings of a transmit call, in one of three caller-owned forms +class IrRfCallData { + public: + /// Get the raw timings (only valid if set via set_raw_timings) + const std::vector &get_raw_timings() const { return *this->raw_timings_; } + /// Check if raw timings have been set (any format) + bool has_raw_timings() const { + return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; + } + /// Check if using packed data format + bool is_packed() const { return this->packed_data_ != nullptr; } + /// Check if using base64url data format + bool is_base64url() const { return this->base64url_ptr_ != nullptr; } + /// Get the base64url data string + const std::string &get_base64url_data() const { return *this->base64url_ptr_; } + /// Get packed data (only valid if set via set_raw_timings_packed) + const uint8_t *get_packed_data() const { return this->packed_data_; } + uint16_t get_packed_length() const { return this->packed_length_; } + uint16_t get_packed_count() const { return this->packed_count_; } + /// Get the repeat count + uint32_t get_repeat_count() const { return this->repeat_count_; } + +#if defined(USE_API) && defined(USE_IR_RF) + /// True for a frame an API client is waiting on + bool wants_api_reply() const { return this->api_connection_ != nullptr; } +#endif + + protected: + uint32_t repeat_count_{1}; +#if defined(USE_API) && defined(USE_IR_RF) + api::APIConnection *api_connection_{nullptr}; +#endif + // Pointer to vector-based timings (caller-owned, must outlive perform()) + const std::vector *raw_timings_{nullptr}; + // Pointer to base64url-encoded string (caller-owned, must outlive perform()) + const std::string *base64url_ptr_{nullptr}; + // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) + const uint8_t *packed_data_{nullptr}; + uint16_t packed_length_{0}; + uint16_t packed_count_{0}; +}; + +template class IrRfCall; + +/// Everything an infrared or radio frequency entity does that does not depend on the medium: +/// the remote_base transport, forwarding received frames to the API, and answering the API +/// once a transmit it started has left the transmitter. +class IrRfEntity : public Component, public EntityBase, public remote_base::RemoteReceiverListener { + public: + /// Reports the configured transports, listens on the receiver and hooks the transmitter's + /// completion; a platform with its own setup() calls it first + void setup() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + /// 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; } + bool has_transmitter() const { return this->transmitter_ != nullptr; } + bool has_receiver() const { return this->receiver_ != nullptr; } + + /// What the entity can do; platforms with their own hardware set these from their own + /// setup(), remote_base ones get them from IrRfEntity::setup() + bool get_supports_transmitter() const { return this->supports_transmitter_; } + void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } + bool get_supports_receiver() const { return this->supports_receiver_; } + void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } + /// Capability flags as reported to the API and web server + uint32_t get_capability_flags() const { + uint32_t flags = 0; + if (this->supports_transmitter_) + flags |= CAPABILITY_TRANSMITTER; + if (this->supports_receiver_) + flags |= CAPABILITY_RECEIVER; + return flags; + } + + /// Forwards a received frame to the API; never consumes it, so other listeners still run + bool on_receive(remote_base::RemoteReceiveData data) override; +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + /// Called for every finished frame on every transmitter; answers the API request when the + /// frame is the one this entity submitted for it, with sent as the outcome + void on_transmit_complete(remote_base::RemoteTransmitterBase *transmitter, uint16_t seq, bool sent); +#endif + +#if defined(USE_API) && defined(USE_IR_RF) + void loop() override; + /// The API server calls this when a client disconnects, so no reply goes to a stale pointer + void on_api_connection_closed(api::APIConnection *conn); +#endif + + protected: + template friend class IrRfCall; + + /// Hands the call's timings to the transmitter; the default transmit path of both entity types + bool transmit_raw_(const IrRfCallData &call, uint32_t carrier_frequency_hz); +#if defined(USE_API) && defined(USE_IR_RF) + // One reply slot: a pacing client has at most one transmit outstanding, and a second request + // from an unpaced client displaces the first. Retried and expired from loop(), which only + // runs while a reply is pending; polling the expiry there costs nothing while idle, where a + // scheduler timeout would allocate per frame. + enum class ApiReply : uint8_t { + API_REPLY_NONE, + API_REPLY_WAITING, // frame handed to the transmitter, completion not reported yet + API_REPLY_OWED_OK, // reply refused by a full TCP buffer; loop() retries it + API_REPLY_OWED_FAILED // same, for a transmit that did not start + }; + /// Claims the reply slot for conn; false when it still owes a reply that cannot be sent + bool expect_api_reply_(api::APIConnection *conn); + void refuse_api_call_(api::APIConnection *conn); + /// After control(): a false start is answered now, and the loop only runs once something is + /// pending, since a blocking transmitter has already answered inside control() + void settle_api_reply_(bool started) { +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + if (!started) + this->finish_api_reply_(false); +#else + // no transmitter in this build reports completion, so the hand-over is the answer + this->finish_api_reply_(started); +#endif + if (this->api_reply_ != ApiReply::API_REPLY_NONE) + this->enable_loop(); + } + void finish_api_reply_(bool success); + /// Answers the API request for a control() override that transmits without a remote_base transmitter + void api_transmit_done_(bool sent) { + if (this->api_reply_ == ApiReply::API_REPLY_WAITING) + this->finish_api_reply_(sent); + } + bool send_api_reply_(); + void clear_api_reply_() { + this->api_reply_ = ApiReply::API_REPLY_NONE; + this->api_reply_connection_ = nullptr; + this->disable_loop(); + } + api::APIConnection *api_reply_connection_{nullptr}; +#endif + + remote_base::RemoteReceiverBase *receiver_{nullptr}; + remote_base::RemoteTransmitterBase *transmitter_{nullptr}; +#if defined(USE_API) && defined(USE_IR_RF) + // 16 ms ticks: 30 s for completion (plus capped air time) or for delivering an owed reply + uint16_t api_reply_deadline_{0}; +#endif +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + uint16_t inflight_seq_{0}; // seq of the API frame this entity submitted last +#endif + // short members last, so the derived traits start on the next word without a gap + bool supports_transmitter_{false}; + bool supports_receiver_{false}; +#if defined(USE_API) && defined(USE_IR_RF) + ApiReply api_reply_{ApiReply::API_REPLY_NONE}; +#endif +}; + +/// Builder for a transmit; Call is the concrete call type and Entity its entity, so the fluent +/// setters return the concrete type and control() sees the medium specific fields +template class IrRfCall : public IrRfCallData { + // only the concrete call may construct the CRTP base + friend Call; + explicit IrRfCall(Entity *parent) : parent_(parent) {} + + public: + // ===== Raw Timings Methods ===== + // All set_raw_timings_* methods store pointers/references to external data. + // The referenced data must remain valid until perform() completes. + // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous + // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! + + /// Set the raw timings from a vector (positive = mark, negative = space) + /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). + /// @note Usage: Primarily for lambdas/automations where the vector is in scope. + Call &set_raw_timings(const std::vector &timings) { + this->raw_timings_ = &timings; + this->packed_data_ = nullptr; + this->base64url_ptr_ = nullptr; + return this->self_(); + } + + /// Set the raw timings from base64url-encoded little-endian int32 data + /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). + /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). + /// @note Decoding happens at perform() time, directly into the transmit buffer. + Call &set_raw_timings_base64url(const std::string &base64url) { + this->base64url_ptr_ = &base64url; + this->raw_timings_ = nullptr; + this->packed_data_ = nullptr; + return this->self_(); + } + + /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) + /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). + /// @note Usage: For API component where data comes directly from the protobuf message. + Call &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { + this->packed_data_ = data; + this->packed_length_ = length; + this->packed_count_ = count; + this->raw_timings_ = nullptr; + this->base64url_ptr_ = nullptr; + return this->self_(); + } + + /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) + Call &set_repeat_count(uint32_t count) { + this->repeat_count_ = count; + return this->self_(); + } + +#if defined(USE_API) && defined(USE_IR_RF) + /// Reply to this API client once the frame has left the transmitter (API 1.18+) + Call &set_api_connection(api::APIConnection *conn) { + this->api_connection_ = conn; + return this->self_(); + } +#endif + + /// Perform the transmission; returns true if a frame was handed to the transmitter + bool perform() { + // make_call() always sets the parent + Entity *parent = this->parent_; + if (parent == nullptr) + return false; + parent->on_call_(this->self_()); +#if defined(USE_API) && defined(USE_IR_RF) + // Before control(): blocking transmitters report completion from inside it, and the + // non-blocking RMT path flushes the previous frame's completion there + if (this->api_connection_ != nullptr && !parent->expect_api_reply_(this->api_connection_)) + return false; +#endif + const bool started = parent->control(this->self_()); +#if defined(USE_API) && defined(USE_IR_RF) + if (this->api_connection_ != nullptr) + parent->settle_api_reply_(started); +#endif + return started; + } + + protected: + Call &self_() { return static_cast(*this); } + + Entity *parent_; +}; + +} // namespace esphome::ir_rf_base diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 288bd916738..548ba2b1326 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,12 +3,7 @@ from typing import Any import esphome.codegen as cg -from esphome.components import ( - infrared, - remote_base, - remote_receiver, - remote_transmitter, -) +from esphome.components import infrared, ir_rf_base, remote_base, remote_receiver from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY @@ -30,7 +25,7 @@ CONFIG_SCHEMA = cv.All( remote_receiver.RemoteReceiverComponent ), cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent + remote_base.RemoteTransmitterBase ), } ), @@ -82,8 +77,7 @@ async def to_code(config: dict[str, Any]) -> None: # Link transmitter if specified if CONF_REMOTE_TRANSMITTER_ID in config: - transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID) # Link receiver if specified if CONF_REMOTE_RECEIVER_ID 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 ceb4c9a67c3..88b15606080 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -8,70 +8,17 @@ namespace esphome::ir_rf_proxy { static const char *const TAG = "ir_rf_proxy"; -// ========== Shared transmit helper ========== -// Static template: all instantiations occur in this translation unit. - -template -static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency, - const CallT &call) { - if (transmitter == nullptr) { - ESP_LOGW(TAG, "No transmitter configured"); - return; - } - - if (!call.has_raw_timings()) { - ESP_LOGE(TAG, "No raw timings provided"); - return; - } - - auto transmit_call = transmitter->transmit(); - auto *transmit_data = transmit_call.get_data(); - transmit_data->set_carrier_frequency(carrier_frequency); - - if (call.is_packed()) { - transmit_data->set_data_from_packed_sint32(call.get_packed_data(), call.get_packed_length(), - call.get_packed_count()); - ESP_LOGD(TAG, "Transmitting packed raw timings: count=%" PRIu16 ", repeat=%" PRIu32, call.get_packed_count(), - call.get_repeat_count()); - } else if (call.is_base64url()) { - if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) { - ESP_LOGE(TAG, "Invalid base64url data"); - return; - } - constexpr int32_t max_timing_us = 500000; - for (int32_t timing : transmit_data->get_data()) { - int32_t abs_timing = timing < 0 ? -timing : timing; - if (abs_timing > max_timing_us) { - ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us); - return; - } - } - ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(), - call.get_repeat_count()); - } else { - transmit_data->set_data(call.get_raw_timings()); - ESP_LOGD(TAG, "Transmitting raw timings: count=%zu, repeat=%" PRIu32, call.get_raw_timings().size(), - call.get_repeat_count()); - } - - if (call.get_repeat_count() > 0) { - transmit_call.set_send_times(call.get_repeat_count()); - } - - transmit_call.perform(); -} - // ========== IrRfProxy (Infrared platform) ========== -#ifdef USE_IR_RF +#ifdef USE_INFRARED void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); if (this->is_rf()) { ESP_LOGCONFIG(TAG, " Hardware Type: RF (%.3f MHz)", this->frequency_khz_ / 1e3f); @@ -80,21 +27,14 @@ void IrRfProxy::dump_config() { } } -void IrRfProxy::control(const infrared::InfraredCall &call) { - uint32_t carrier = call.get_carrier_frequency().value_or(0); - transmit_raw_timings(this->transmitter_, carrier, call); -} - -#endif // USE_IR_RF +#endif // USE_INFRARED // ========== RfProxy (Radio Frequency platform) ========== #ifdef USE_RADIO_FREQUENCY void RfProxy::setup() { - this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); - this->traits_.set_supports_receiver(this->receiver_ != nullptr); - + ir_rf_base::IrRfEntity::setup(); // remote_transmitter/receiver always uses OOK (on-off keying) this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); } @@ -104,8 +44,8 @@ void RfProxy::dump_config() { "RF Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); const auto &traits = this->traits_; if (traits.get_frequency_min_hz() > 0) { @@ -118,11 +58,11 @@ void RfProxy::dump_config() { } } -void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { +bool RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { // RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning) // happens via the radio_frequency entity's on_control trigger and remote_transmitter's // on_transmit/on_complete triggers — wired up in user YAML. - transmit_raw_timings(this->transmitter_, 0, call); + return this->transmit_raw_(call, 0); } #endif // USE_RADIO_FREQUENCY diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 1aa4394fe84..a45b0ac4722 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.h +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.h @@ -6,7 +6,7 @@ #include "esphome/components/remote_base/remote_base.h" -#ifdef USE_IR_RF +#ifdef USE_INFRARED #include "esphome/components/infrared/infrared.h" #endif @@ -16,7 +16,7 @@ namespace esphome::ir_rf_proxy { -#ifdef USE_IR_RF +#ifdef USE_INFRARED /// IrRfProxy - Infrared platform implementation using remote_transmitter/receiver as backend class IrRfProxy final : public infrared::Infrared { public: @@ -35,12 +35,10 @@ class IrRfProxy final : public infrared::Infrared { void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); } protected: - void control(const infrared::InfraredCall &call) override; - // RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF uint32_t frequency_khz_{0}; }; -#endif // USE_IR_RF +#endif // USE_INFRARED #ifdef USE_RADIO_FREQUENCY /// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend. @@ -54,20 +52,11 @@ class RfProxy final : public radio_frequency::RadioFrequency { void setup() override; void dump_config() override; - /// Set the remote transmitter component - void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - /// 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) void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } protected: - void control(const radio_frequency::RadioFrequencyCall &call) override; - - remote_base::RemoteTransmitterBase *transmitter_{nullptr}; - remote_base::RemoteReceiverBase *receiver_{nullptr}; + bool control(const radio_frequency::RadioFrequencyCall &call) override; }; #endif // USE_RADIO_FREQUENCY diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index 28b8fd5953a..1519fe9908a 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,12 +1,7 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import ( - radio_frequency, - remote_base, - remote_receiver, - remote_transmitter, -) +from esphome.components import ir_rf_base, radio_frequency, remote_base, remote_receiver import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -27,7 +22,7 @@ CONFIG_SCHEMA = cv.All( remote_receiver.RemoteReceiverComponent ), cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent + remote_base.RemoteTransmitterBase ), } ), @@ -67,8 +62,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) if CONF_REMOTE_TRANSMITTER_ID in config: - transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID) if CONF_REMOTE_RECEIVER_ID in config: 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 237f1c3c8bd..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); } 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 1b1eefe59b3..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,7 +64,34 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } -JsonBuilder::JsonBuilder() = default; +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() { // =========================================================================================== @@ -109,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); @@ -134,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 130e1503321..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,11 +167,88 @@ 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_) { @@ -179,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/kmeteriso/sensor.py b/esphome/components/kmeteriso/sensor.py index 3e007d13105..5b7087362f9 100644 --- a/esphome/components/kmeteriso/sensor.py +++ b/esphome/components/kmeteriso/sensor.py @@ -48,9 +48,6 @@ 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)) - 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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) 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/sensor.py b/esphome/components/lc709203f/sensor.py index 3319c9be4b9..0281609edfa 100644 --- a/esphome/components/lc709203f/sensor.py +++ b/esphome/components/lc709203f/sensor.py @@ -79,15 +79,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pack_size(config.get(CONF_SIZE))) cg.add(var.set_pack_voltage(BATTERY_VOLTAGE_OPTIONS[config[CONF_VOLTAGE]])) - if voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_remaining_sensor) - if level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(level_config) - cg.add(var.set_battery_remaining_sensor(sens)) - - if temp_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temp_config) - cg.add(var.set_temperature_sensor(sens)) - cg.add(var.set_thermistor_b_constant(temp_config[CONF_B_CONSTANT])) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + b_constant = config[CONF_TEMPERATURE][CONF_B_CONSTANT] + cg.add(var.set_thermistor_b_constant(b_constant)) 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 f27e241491d..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]) - 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]) - 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/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/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/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/ld2450.h b/esphome/components/ld2450/ld2450.h index c4f06ad2248..d6285cce8af 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -176,6 +176,10 @@ class LD2450Component : public Component, public uart::UARTDevice { bool bluetooth_on_{false}; Target target_info_[MAX_TARGETS]; Zone zone_config_[MAX_ZONES]; +#ifdef USE_TEXT_SENSOR + // Byte aligned, so it packs after zone_config_ instead of padding before data_callback_ + std::array, MAX_TARGETS> direction_dedup_{}; +#endif #ifdef USE_NUMBER ESPPreferenceObject pref_; // only used when numbers are in use @@ -194,7 +198,6 @@ class LD2450Component : public Component, public uart::UARTDevice { #endif #ifdef USE_TEXT_SENSOR std::array direction_text_sensors_{}; - std::array, MAX_TARGETS> direction_dedup_{}; #endif LazyCallbackManager data_callback_; 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/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/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/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/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..1df0f675729 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 @@ -36,25 +35,11 @@ class LEDCOutput final : public output::FloatOutput, public Component { InternalGPIOPin *pin_; uint8_t channel_{}; uint8_t bit_depth_{}; + bool initialized_ = false; float phase_angle_{0.0f}; float frequency_{}; float duty_{0.0f}; uint32_t last_duty_{UINT32_MAX}; - 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 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..5d630fec6cc 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -32,6 +32,7 @@ from esphome.storage_json import StorageJSON from . import gpio # noqa: F401 from .const import ( COMPONENT_BK72XX, + COMPONENT_LN882X, CONF_GPIO_RECOVER, CONF_LOGLEVEL, CONF_SDK_SILENT, @@ -490,6 +491,10 @@ async def component_to_code(config): # Not enabled on RTL87xx/LN882x — costs more heap than it saves there. cg.add_build_flag("-DconfigSUPPORT_STATIC_ALLOCATION=1") + # LN882x: a zero-size allocation must not trip the SDK's assert (see ln882x_zero_malloc.c). + if config[CONF_COMPONENT_ID] == COMPONENT_LN882X: + cg.add_build_flag("-Wl,--wrap=pvPortMalloc") + # RTL8710B needs FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake # required by AsyncTCP 3.4.3+ (https://github.com/esphome/esphome/issues/10220) # RTL8720C (ambz2) requires FreeRTOS 10.x so this only applies to RTL8710B @@ -513,12 +518,18 @@ 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 # RAM-executable output section and prints a post-link placement summary. if FAMILY_COMPONENT[config[CONF_FAMILY]] != COMPONENT_BK72XX: cg.add_platformio_option("extra_scripts", ["pre:patch_linker.py"]) + # Match the 1-byte enums of the LN882H SDK's prebuilt WiFi library. + if FAMILY_COMPONENT[config[CONF_FAMILY]] == COMPONENT_LN882X: + cg.add_build_unflag("-fno-short-enums") + cg.add_build_flag("-fshort-enums") # dummy version code cg.add_define("USE_ARDUINO_VERSION_CODE", cg.RawExpression("VERSION_CODE(0, 0, 0)")) # decrease web server stack size (16k words -> 4k words) @@ -618,4 +629,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/core.cpp b/esphome/components/libretiny/core.cpp index 8686a41e646..1f5de35d2cb 100644 --- a/esphome/components/libretiny/core.cpp +++ b/esphome/components/libretiny/core.cpp @@ -1,6 +1,26 @@ #ifdef USE_LIBRETINY -// HAL functions live in hal.cpp. core.cpp is intentionally empty for -// libretiny — there is no extra component bootstrap to keep here. +// HAL functions live in hal.cpp; this file only holds the serial port +// workaround below. + +#include "core.h" + +#ifdef USE_LN882X +#include + +// The SDK's open port handles (components/serial/serial.c); not in its header. +extern "C" Serial_t *serial_handles[SER_PORT_NUM]; + +namespace esphome::libretiny { + +void ensure_serial_handle(uint8_t port) { + if (port >= SER_PORT_NUM || serial_handles[port] != nullptr) + return; + // serial_init() zeroes and opens it; the port stays open for good. + serial_handles[port] = new Serial_t(); +} + +} // namespace esphome::libretiny +#endif // USE_LN882X #endif // USE_LIBRETINY diff --git a/esphome/components/libretiny/core.h b/esphome/components/libretiny/core.h index f909db4f0f8..8f6760c0fc1 100644 --- a/esphome/components/libretiny/core.h +++ b/esphome/components/libretiny/core.h @@ -4,6 +4,21 @@ #include -namespace esphome::libretiny {} // namespace esphome::libretiny +namespace esphome::libretiny { + +#ifdef USE_LN882X +/// Give a hardware UART its SDK handle before its Serial begin()s. +/// +/// LibreTiny's LN882H Serial passes the SDK's per port handle to +/// serial_init(), which fills it in, but only the default log port (UART1) +/// has one at boot. Any other port hands it NULL and the chip faults before +/// setup, so a logger or uart on UART0 never boots. Drop this once LibreTiny +/// gives each port storage of its own. +void ensure_serial_handle(uint8_t port); +#else +inline void ensure_serial_handle(uint8_t /*port*/) {} +#endif + +} // namespace esphome::libretiny #endif // USE_LIBRETINY 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/hal.cpp b/esphome/components/libretiny/hal.cpp index 01b276005dd..f97a5a19a7a 100644 --- a/esphome/components/libretiny/hal.cpp +++ b/esphome/components/libretiny/hal.cpp @@ -12,6 +12,12 @@ // libretiny component's API. namespace esphome::libretiny {} // namespace esphome::libretiny +#if defined(USE_LN882X) && !defined(CLANG_TIDY) // clang-tidy runs without the PlatformIO flags +// Fail the build if the -fshort-enums swap in __init__.py stops working. +enum Ln882xEnumSizeCheck { LN882X_ENUM_SIZE_CHECK }; +static_assert(sizeof(Ln882xEnumSizeCheck) == 1, "LN882x must build with -fshort-enums to match the SDK"); +#endif + namespace esphome { // yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(), diff --git a/esphome/components/libretiny/ln882x_zero_malloc.c b/esphome/components/libretiny/ln882x_zero_malloc.c new file mode 100644 index 00000000000..77e30b3a7dd --- /dev/null +++ b/esphome/components/libretiny/ln882x_zero_malloc.c @@ -0,0 +1,19 @@ +/* + * LN882x: heap_5 returns NULL for a zero-size request, and the SDK's + * malloc-failed hook ends in LN_ASSERT(0), which spins until the watchdog + * resets the chip. LibreTiny's scan handler asks for zero entries when a scan + * finds no networks. Linked with -Wl,--wrap=pvPortMalloc so every allocation + * comes through here; drop it once LibreTiny no longer allocates zero entries. + */ + +#ifdef USE_LN882X + +#include + +void *__real_pvPortMalloc(size_t size); // NOLINT(readability-identifier-naming) + +void *__wrap_pvPortMalloc(size_t size) { // NOLINT(readability-identifier-naming) + return __real_pvPortMalloc(size == 0 ? 1 : size); +} + +#endif // USE_LN882X diff --git a/esphome/components/libretiny/lt_component.cpp b/esphome/components/libretiny/lt_component.cpp index 0ab064e3e19..d7d93249c51 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -2,6 +2,9 @@ #ifdef USE_LIBRETINY +#include + +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::libretiny { @@ -18,6 +21,17 @@ void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, " Optimization: -Os, SDK: -O" STRINGIFY_MACRO(__OPTIMIZE_LEVEL__)); #endif +#if defined(USE_LN882X) && defined(USE_WIFI) + // The SDK falls back to this MAC when the board layout's KV store holds none. + static constexpr uint8_t SDK_DEFAULT_MAC[MAC_ADDRESS_SIZE] = {0x00, 0x50, 0xC2, 0x5E, 0x10, 0x88}; + uint8_t mac[MAC_ADDRESS_SIZE]; + get_mac_address_raw(mac); + if (memcmp(mac, SDK_DEFAULT_MAC, MAC_ADDRESS_SIZE) == 0) { + ESP_LOGE(TAG, "MAC is the LN882H SDK default 00:50:C2:5E:10:88; only one device with it can be on a network. " + "On a Tuya module, set board: to its Tuya board (e.g. wl2s, wl2h-u) and flash over serial"); + } +#endif + #ifdef USE_TEXT_SENSOR if (this->version_ != nullptr) { this->version_->publish_state(<_BANNER_STR[10]); 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/text_sensor.py b/esphome/components/libretiny/text_sensor.py index c1012774c88..18b412c460c 100644 --- a/esphome/components/libretiny/text_sensor.py +++ b/esphome/components/libretiny/text_sensor.py @@ -25,8 +25,7 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): - lt_component = await cg.get_variable(config[CONF_LIBRETINY]) + hub = await cg.get_variable(config[CONF_LIBRETINY]) - if CONF_VERSION in config: - sens = await text_sensor.new_text_sensor(config[CONF_VERSION]) - cg.add(lt_component.set_version_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, hub.set_version_sensor) 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 ab9624c3649..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,21 +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) @@ -350,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), @@ -372,6 +395,7 @@ LIGHT_SCHEMA = ( } ), cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA, + cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean, } ) ) @@ -393,6 +417,11 @@ BRIGHTNESS_ONLY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend( cv.Optional( 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), } ) @@ -406,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) @@ -424,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, @@ -439,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): @@ -476,31 +521,46 @@ 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) @@ -512,10 +572,14 @@ async def setup_light_core_(light_var, config, output_var): ) 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( 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/base_light_effects.h b/esphome/components/light/base_light_effects.h index ba3fba6c12d..e32dbde9521 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -163,6 +163,11 @@ struct StrobeLightEffectColor { class StrobeLightEffect : public LightEffect { public: explicit StrobeLightEffect(const char *name) : LightEffect(name) {} + void start() override { + // Place the cycle at the end of the last color, so the first apply() switches straight to the first color + this->at_color_ = this->colors_.size() - 1; + this->last_switch_ = millis() - this->colors_.back().duration; + } void apply() override { const uint32_t now = millis(); if (now - this->last_switch_ < this->colors_[this->at_color_].duration) diff --git a/esphome/components/light/effects.py b/esphome/components/light/effects.py index 3ae15f9ee5d..dc6266b2fd2 100644 --- a/esphome/components/light/effects.py +++ b/esphome/components/light/effects.py @@ -31,8 +31,10 @@ from esphome.const import ( ) from esphome.cpp_generator import MockObjClass from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.util import Registry +from .automation import COLOR_SCHEMA, color_to_rgb, validate_light_state from .types import ( COLOR_MODES, AddressableColorWipeEffect, @@ -87,6 +89,28 @@ MONOCHROMATIC_EFFECTS = [] RGB_EFFECTS = [] ADDRESSABLE_EFFECTS = [] + +def _with_color_option(schema: cv.Schema, *default_keys: str) -> cv.Schema: + """Add the `color:` option to a per-item effect color schema. + + `color_to_rgb` must run before the keys are defaulted to full level below, so a + `color:` can populate `default_keys` (red/green/blue and, where the effect has + one, color_brightness) and a plain channel list still defaults them to full + level. Centralizing both steps here means a new effect schema only has to call + this once instead of repeating the ordering rule itself. + """ + schema = schema.extend(COLOR_SCHEMA) + schema.add_extra(color_to_rgb) + + def _default_to_full_level(config: ConfigType) -> ConfigType: + for key in default_keys: + config.setdefault(key, 1.0) + return config + + schema.add_extra(_default_to_full_level) + return schema + + EFFECTS_REGISTRY = Registry() @@ -266,6 +290,30 @@ async def random_effect_to_code(config, effect_id): return effect +STROBE_COLOR_SCHEMA = cv.Schema( + { + 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="_"), + cv.Optional(CONF_COLOR_BRIGHTNESS): cv.percentage, + cv.Optional(CONF_RED): cv.percentage, + cv.Optional(CONF_GREEN): cv.percentage, + cv.Optional(CONF_BLUE): cv.percentage, + cv.Optional(CONF_WHITE, default=1.0): cv.percentage, + cv.Optional(CONF_COLOR_TEMPERATURE): cv.color_temperature, + cv.Optional(CONF_COLD_WHITE, default=1.0): cv.percentage, + cv.Optional(CONF_WARM_WHITE, default=1.0): cv.percentage, + cv.Required(CONF_DURATION): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TRANSITION_LENGTH, default="0s" + ): cv.positive_time_period_milliseconds, + } +) +STROBE_COLOR_SCHEMA = _with_color_option( + STROBE_COLOR_SCHEMA, CONF_COLOR_BRIGHTNESS, CONF_RED, CONF_GREEN, CONF_BLUE +) + + @register_binary_effect( "strobe", StrobeLightEffect, @@ -279,29 +327,7 @@ async def random_effect_to_code(config, effect_id): ], ): cv.All( cv.ensure_list( - cv.Schema( - { - cv.Optional(CONF_STATE, default=True): cv.boolean, - cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, - cv.Optional(CONF_COLOR_MODE): cv.enum( - COLOR_MODES, upper=True, space="_" - ), - cv.Optional(CONF_COLOR_BRIGHTNESS, default=1.0): cv.percentage, - cv.Optional(CONF_RED, default=1.0): cv.percentage, - cv.Optional(CONF_GREEN, default=1.0): cv.percentage, - cv.Optional(CONF_BLUE, default=1.0): cv.percentage, - cv.Optional(CONF_WHITE, default=1.0): cv.percentage, - cv.Optional(CONF_COLOR_TEMPERATURE): cv.color_temperature, - cv.Optional(CONF_COLD_WHITE, default=1.0): cv.percentage, - cv.Optional(CONF_WARM_WHITE, default=1.0): cv.percentage, - cv.Required( - CONF_DURATION - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TRANSITION_LENGTH, default="0s" - ): cv.positive_time_period_milliseconds, - } - ), + STROBE_COLOR_SCHEMA, cv.has_at_least_one_key( CONF_STATE, CONF_BRIGHTNESS, @@ -405,6 +431,27 @@ async def addressable_rainbow_effect_to_code(config, effect_id): return var +ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = cv.Schema( + { + cv.Optional(CONF_COLOR_BRIGHTNESS): cv.percentage, + cv.Optional(CONF_RED): cv.percentage, + cv.Optional(CONF_GREEN): cv.percentage, + cv.Optional(CONF_BLUE): cv.percentage, + cv.Optional(CONF_WHITE, default=1.0): cv.percentage, + cv.Optional(CONF_RANDOM, default=False): cv.boolean, + cv.Required(CONF_NUM_LEDS): cv.All(cv.uint32_t, cv.Range(min=1)), + cv.Optional(CONF_GRADIENT, default=False): cv.boolean, + } +) +ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = _with_color_option( + ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA, + CONF_COLOR_BRIGHTNESS, + CONF_RED, + CONF_GREEN, + CONF_BLUE, +) + + @register_addressable_effect( "addressable_color_wipe", AddressableColorWipeEffect, @@ -412,17 +459,7 @@ async def addressable_rainbow_effect_to_code(config, effect_id): { cv.Optional( CONF_COLORS, default=[{CONF_NUM_LEDS: 1, CONF_RANDOM: True}] - ): cv.ensure_list( - { - cv.Optional(CONF_RED, default=1.0): cv.percentage, - cv.Optional(CONF_GREEN, default=1.0): cv.percentage, - cv.Optional(CONF_BLUE, default=1.0): cv.percentage, - cv.Optional(CONF_WHITE, default=1.0): cv.percentage, - cv.Optional(CONF_RANDOM, default=False): cv.boolean, - cv.Required(CONF_NUM_LEDS): cv.All(cv.uint32_t, cv.Range(min=1)), - cv.Optional(CONF_GRADIENT, default=False): cv.boolean, - } - ), + ): cv.ensure_list(ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA), cv.Optional( CONF_ADD_LED_INTERVAL, default="0.1s" ): cv.positive_time_period_milliseconds, @@ -433,19 +470,21 @@ async def addressable_color_wipe_effect_to_code(config, effect_id): var = cg.new_Pvariable(effect_id, config[CONF_NAME]) cg.add(var.set_add_led_interval(config[CONF_ADD_LED_INTERVAL])) cg.add(var.set_reverse(config[CONF_REVERSE])) - colors = [ - cg.StructInitializer( - AddressableColorWipeEffectColor, - ("r", int(round(color[CONF_RED] * 255))), - ("g", int(round(color[CONF_GREEN] * 255))), - ("b", int(round(color[CONF_BLUE] * 255))), - ("w", int(round(color[CONF_WHITE] * 255))), - ("random", color[CONF_RANDOM]), - ("num_leds", color[CONF_NUM_LEDS]), - ("gradient", color[CONF_GRADIENT]), + colors = [] + for color in config.get(CONF_COLORS, []): + color_brightness = color[CONF_COLOR_BRIGHTNESS] + colors.append( + cg.StructInitializer( + AddressableColorWipeEffectColor, + ("r", int(round(color[CONF_RED] * color_brightness * 255))), + ("g", int(round(color[CONF_GREEN] * color_brightness * 255))), + ("b", int(round(color[CONF_BLUE] * color_brightness * 255))), + ("w", int(round(color[CONF_WHITE] * 255))), + ("random", color[CONF_RANDOM]), + ("num_leds", color[CONF_NUM_LEDS]), + ("gradient", color[CONF_GRADIENT]), + ) ) - for color in config.get(CONF_COLORS, []) - ] cg.add(var.set_colors(colors)) return var @@ -544,12 +583,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..7e54fccebca 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -156,11 +156,13 @@ void LightCall::perform() { ESP_LOGV(TAG, " Effect: '%.*s'", (int) effect_s.size(), effect_s.c_str()); } - this->parent_->start_effect_(this->effect_); + if (this->effect_ != this->parent_->active_effect_index_) { + this->parent_->start_effect_(this->effect_); - // Also set light color values when starting an effect - // For example to turn off the light - this->parent_->set_immediately_(v, true); + // Also set light color values when starting an effect + // For example to turn off the light + this->parent_->set_immediately_(v, true); + } } else { // INSTANT CHANGE this->parent_->set_immediately_(v, publish); @@ -193,6 +195,10 @@ LightColorValues LightCall::validate_() { auto *name = this->parent_->get_name().c_str(); auto traits = this->parent_->get_traits(); + // Snapshot before the adjustments below add flags of their own + const bool sets_values = (this->flags_ & VALUE_FLAGS_MASK) != 0; + const bool plain_turn_on = this->has_state() && this->state_ && (this->flags_ & ~STATE_ONLY_FLAGS_MASK) == 0; + // 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,9 +340,34 @@ LightColorValues LightCall::validate_() { // validate transition length/flash length/effect not used at the same time bool supports_transition = color_mode & ColorCapability::BRIGHTNESS; - // If effect is already active, remove effect start +#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 + + // A plain turn-on of a lit light keeps a running effect as it is. + // Effects' own calls don't publish, so they are not caught here. + if (plain_turn_on && this->get_publish_() && this->parent_->remote_values.is_on() && + this->parent_->active_effect_index_ != 0) { + this->effect_ = this->parent_->active_effect_index_; + this->set_flag_(FLAG_HAS_EFFECT); + } + + // If effect is already active, remove effect start. When a lit light that stays on gets no new values or flash, + // keep the flag and drop any transition, which has nothing to fade to; perform() then leaves the running effect + // undisturbed. A call that turns the light off keeps no flag, so the turn-off block below stops the effect. + // has_brightness() catches the brightness added above to make the turn-on visible. if (this->has_effect_() && this->effect_ == this->parent_->active_effect_index_) { - this->clear_flag_(FLAG_HAS_EFFECT); + if (sets_values || this->has_brightness() || this->has_flash_() || this->effect_ == 0 || + !this->parent_->remote_values.is_on() || !v.is_on()) { + this->clear_flag_(FLAG_HAS_EFFECT); + } else { + this->clear_flag_(FLAG_HAS_TRANSITION); + } } // validate effect index @@ -376,6 +407,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..a226690853e 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -215,6 +215,9 @@ 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; + static constexpr uint16_t VALUE_FLAGS_MASK = CLAMP_FLAGS_MASK | FLAG_HAS_COLOR_TEMPERATURE | FLAG_HAS_COLOR_MODE; 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 eafa161f51e..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_{}; // Keep in sync with DEFAULT_FLASH_TRANSITION_LENGTH in __init__.py - /// Gamma correction factor for the light. - float gamma_correct_{}; +#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 138db75ad10..a966ba67ad8 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,27 @@ 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_task_log_buffer_alignment(value: int) -> int: + # ESP-IDF rejects a no-split ring buffer whose size is not a multiple of 4 + if CORE.is_esp32 and value % 4: + raise cv.Invalid( + f"{CONF_TASK_LOG_BUFFER_SIZE} must be a multiple of 4 on ESP32" + ) + return value + + 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 +257,6 @@ LoggerMessageTrigger = logger_ns.class_( ) -CONF_ESP8266_STORE_LOG_STRINGS_IN_FLASH = "esp8266_store_log_strings_in_flash" CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -267,6 +291,7 @@ CONFIG_SCHEMA = cv.All( max=32768, # Max: Depends on message sizes, typically ~300 messages with default size ), ), + validate_task_log_buffer_alignment, ), cv.SplitDefault( CONF_HARDWARE_UART, @@ -332,6 +357,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, ) @@ -450,9 +476,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) @@ -491,6 +514,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..019d13950a3 100644 --- a/esphome/components/logger/log_buffer.h +++ b/esphome/components/logger/log_buffer.h @@ -3,11 +3,14 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" namespace esphome::logger { // Maximum header size: 35 bytes fixed + 32 bytes tag + 16 bytes thread name = 83 bytes (45 byte safety margin) static constexpr uint16_t MAX_HEADER_SIZE = 128; +// Longest tag the header budget allows +static constexpr size_t MAX_TAG_LENGTH = 32; // ANSI color code last digit (30-38 range, store only last digit to save RAM on ESP8266) static const char LOG_LEVEL_COLOR_DIGIT[] PROGMEM = { @@ -105,9 +108,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 +161,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)); @@ -189,13 +192,23 @@ struct LogBuffer { *p++ = 'm'; } // Copy string without null terminator, updates pointer in place - // Caller is responsible for ensuring buffer has sufficient space + // Caller is responsible for ensuring buffer has sufficient space. Tags may be in PROGMEM on ESP8266. void copy_string_(char *&p, const char *str) { +#ifdef USE_ESP8266 + // Only ESP8266 caps the length; tags may be in PROGMEM, so read each byte once + for (size_t i = 0; i < MAX_TAG_LENGTH; i++) { + const char c = static_cast(progmem_read_byte(reinterpret_cast(str + i))); + if (c == '\0') + break; + *p++ = c; + } +#else const size_t len = strlen(str); // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - intentionally no null terminator, building string piece by // piece memcpy(p, str, len); p += len; +#endif } }; diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index bfc005070e7..2a0a1c9c0ac 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,11 +140,14 @@ 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 - auto it = this->log_levels_.find(tag); + // Skip the lookup when no per-tag levels are set + if (this->log_levels_.empty()) + return this->current_level_; + auto it = this->log_levels_.find(FlashTag{tag}); if (it != this->log_levels_.end()) return it->second; #endif diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index ae55f4145a9..b82aaa7c219 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -12,6 +12,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "log_buffer.h" #include "task_log_buffer_host.h" @@ -85,12 +86,16 @@ class LoggerLevelListener { }; #endif -#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS -// Comparison function for const char* keys in log_levels_ map +// RAM map keys compared with a tag that may be in PROGMEM, without copying it +struct FlashTag { + const char *tag; +}; struct CStrCompare { + using is_transparent = void; bool operator()(const char *a, const char *b) const { return strcmp(a, b) < 0; } + bool operator()(const char *key, FlashTag t) const { return ESPHOME_strcmp_P(key, t.tag) < 0; } + bool operator()(FlashTag t, const char *key) const { return ESPHOME_strcmp_P(key, t.tag) > 0; } }; -#endif // Stack buffer size for retrieving thread/task names from the OS // macOS allows up to 64 bytes, Linux up to 16 @@ -200,9 +205,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 +249,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 +288,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 { @@ -488,6 +493,12 @@ class LoggerMessageTrigger final : public Trigger(self); if (level <= trigger->level_) { +#ifdef USE_ESP8266 + // User lambdas may strcmp the tag, which may be in PROGMEM. The copy lives in + // the trigger so an automation that suspends (delay) still sees a valid tag. + ESPHOME_strncpy_P(trigger->ram_tag_, tag, MAX_TAG_LENGTH); + tag = trigger->ram_tag_; +#endif trigger->trigger(level, tag, message); } }); @@ -495,6 +506,9 @@ class LoggerMessageTrigger final : public Triggerhw_serial_ = &Serial0; + libretiny::ensure_serial_handle(0); Serial0.begin(this->baud_rate_); break; #endif #if LT_HW_UART1 case UART_SELECTION_UART1: this->hw_serial_ = &Serial1; + libretiny::ensure_serial_handle(1); Serial1.begin(this->baud_rate_); break; #endif #if LT_HW_UART2 case UART_SELECTION_UART2: this->hw_serial_ = &Serial2; + libretiny::ensure_serial_handle(2); Serial2.begin(this->baud_rate_); break; #endif diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index b7884b702ba..396728ceaf0 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -20,6 +20,13 @@ __attribute__((weak)) void print_coredump() {} namespace esphome::logger { +// Zephyr 3.7 renamed z_arch_esf_t to struct arch_esf; the old name was later removed. +#if KERNEL_VERSION_NUMBER >= 0x030700 +using FatalErrorEsf = ::arch_esf; +#else +using FatalErrorEsf = z_arch_esf_t; +#endif + __attribute__((section(".noinit"))) struct { uint32_t magic; uint32_t reason; @@ -54,6 +61,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 +95,7 @@ void Logger::pre_setup() { #endif } } +#endif global_logger = this; ESP_LOGI(TAG, "Log initialized"); #ifdef USE_LOGGER_EARLY_MESSAGE @@ -166,7 +175,7 @@ void Logger::dump_crash_() { } } -void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf) { +void k_sys_fatal_error_handler(unsigned int reason, const FatalErrorEsf *esf) { crash_buf.magic = App.get_config_hash(); crash_buf.reason = reason; if (esf) { @@ -190,7 +199,7 @@ void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf) { extern "C" { -void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf) { +void k_sys_fatal_error_handler(unsigned int reason, const esphome::logger::FatalErrorEsf *esf) { esphome::logger::k_sys_fatal_error_handler(reason, esf); } } diff --git a/esphome/components/logger/task_log_buffer_esp32.cpp b/esphome/components/logger/task_log_buffer_esp32.cpp index cb97f5504fa..75b567ff892 100644 --- a/esphome/components/logger/task_log_buffer_esp32.cpp +++ b/esphome/components/logger/task_log_buffer_esp32.cpp @@ -10,15 +10,7 @@ namespace esphome::logger { TaskLogBuffer::TaskLogBuffer() { // Create a static ring buffer with RINGBUF_TYPE_NOSPLIT for message integrity // Storage is a member array (embedded in Logger), no heap allocation needed - this->ring_buffer_ = - xRingbufferCreateStatic(sizeof(this->storage_), RINGBUF_TYPE_NOSPLIT, this->storage_, &this->structure_); -} - -TaskLogBuffer::~TaskLogBuffer() { - if (this->ring_buffer_ != nullptr) { - vRingbufferDelete(this->ring_buffer_); - this->ring_buffer_ = nullptr; - } + xRingbufferCreateStatic(sizeof(this->storage_), RINGBUF_TYPE_NOSPLIT, this->storage_, &this->structure_); } bool TaskLogBuffer::borrow_message_main_loop(LogMessage *&message, uint16_t &text_length) { @@ -27,7 +19,7 @@ bool TaskLogBuffer::borrow_message_main_loop(LogMessage *&message, uint16_t &tex } size_t item_size = 0; - void *received_item = xRingbufferReceive(ring_buffer_, &item_size, 0); + void *received_item = xRingbufferReceive(this->handle_(), &item_size, 0); if (received_item == nullptr) { return false; } @@ -44,7 +36,7 @@ void TaskLogBuffer::release_message_main_loop() { if (this->current_token_ == nullptr) { return; } - vRingbufferReturnItem(ring_buffer_, this->current_token_); + vRingbufferReturnItem(this->handle_(), this->current_token_); this->current_token_ = nullptr; // Update counter to mark all messages as processed last_processed_counter_ = message_counter_.load(std::memory_order_relaxed); @@ -71,7 +63,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin // Acquire memory directly from the ring buffer void *acquired_memory = nullptr; - BaseType_t result = xRingbufferSendAcquire(ring_buffer_, &acquired_memory, total_size, 0); + BaseType_t result = xRingbufferSendAcquire(this->handle_(), &acquired_memory, total_size, 0); if (result != pdTRUE || acquired_memory == nullptr) { return false; // Failed to acquire memory @@ -100,7 +92,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin // Handle unexpected formatting error if (ret <= 0) { - vRingbufferReturnItem(ring_buffer_, acquired_memory); + vRingbufferReturnItem(this->handle_(), acquired_memory); return false; } @@ -111,7 +103,7 @@ bool TaskLogBuffer::send_message_thread_safe(uint8_t level, const char *tag, uin msg->text_length = text_length; // Complete the send operation with the acquired memory - result = xRingbufferSendComplete(ring_buffer_, acquired_memory); + result = xRingbufferSendComplete(this->handle_(), acquired_memory); if (result != pdTRUE) { return false; // Failed to complete the message send diff --git a/esphome/components/logger/task_log_buffer_esp32.h b/esphome/components/logger/task_log_buffer_esp32.h index e8197667954..0ffaa044931 100644 --- a/esphome/components/logger/task_log_buffer_esp32.h +++ b/esphome/components/logger/task_log_buffer_esp32.h @@ -47,7 +47,7 @@ class TaskLogBuffer { }; TaskLogBuffer(); - ~TaskLogBuffer(); + // No destructor: Logger is never destroyed // NOT thread-safe - borrow a message from the ring buffer, only call from main loop bool borrow_message_main_loop(LogMessage *&message, uint16_t &text_length); @@ -68,7 +68,11 @@ class TaskLogBuffer { static constexpr size_t size() { return ESPHOME_TASK_LOG_BUFFER_SIZE; } private: - RingbufHandle_t ring_buffer_{nullptr}; // FreeRTOS ring buffer handle + // xRingbufferCreateStatic() returns the static structure itself as the handle; it only + // returns NULL for a no-split size that is unaligned or under two item headers + static_assert(ESPHOME_TASK_LOG_BUFFER_SIZE % 4 == 0, "task_log_buffer_size must be a multiple of 4"); + RingbufHandle_t handle_() { return &this->structure_; } + StaticRingbuffer_t structure_; // Static structure for the ring buffer uint8_t storage_[ESPHOME_TASK_LOG_BUFFER_SIZE]; // Embedded in Logger (no separate heap allocation) diff --git a/esphome/components/lps22/sensor.py b/esphome/components/lps22/sensor.py index 2eec2c586c5..f219639eeea 100644 --- a/esphome/components/lps22/sensor.py +++ b/esphome/components/lps22/sensor.py @@ -50,10 +50,6 @@ 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)) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) 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..66aaac5301f 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, @@ -47,6 +48,7 @@ from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -60,7 +62,6 @@ from .defines import ( get_focused_widgets, get_lv_images_used, get_refreshed_widgets, - set_widgets_completed, ) from .encoders import ( ENCODERS_CONFIG, @@ -107,6 +108,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 +171,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 +239,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 +395,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 +454,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 +464,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,13 +635,22 @@ 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) ) +def _not_on_esp8266(config: ConfigType) -> ConfigType: + # ESP8266 does not have enough RAM for LVGL to be practical. + if CORE.is_esp8266: + raise cv.Invalid("LVGL is not supported on ESP8266") + return config + + LVGL_SCHEMA = cv.All( + _not_on_esp8266, container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA), cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT), add_hello_world, 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 c23a36c3892..d797688a7e8 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -78,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 @@ -127,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) @@ -145,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 @@ -153,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( @@ -507,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 73fc58736bb..c019dff96e0 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -20,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( @@ -102,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, {}) @@ -110,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]]: @@ -130,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()) @@ -152,6 +172,10 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) +def get_list_triggers() -> dict: + return _get_data(KEY_LIST_TRIGGERS, {}) + + def add_define(macro: str, value="1"): lv_defines = get_defines() value = str(value) @@ -680,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" @@ -767,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" @@ -843,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 @@ -853,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 6f86e49e511..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 @@ -64,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): @@ -254,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) @@ -307,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..feffac28b33 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): """ @@ -188,9 +209,6 @@ class LvContext(LambdaContext): def __init__(self): super().__init__(parameters=LVGL_COMP_ARG) - async def __aexit__(self, exc_type, exc_val, exc_tb): - await super().__aexit__(exc_type, exc_val, exc_tb) - def add(self, expression: Expression | Statement): cg.add(expression) return expression @@ -212,10 +230,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..466d4000235 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); } @@ -1036,7 +1037,7 @@ void lv_mem_init() {} void lv_mem_deinit() {} -#if defined(USE_HOST) || defined(USE_RP2) || defined(USE_ESP8266) +#ifndef USE_ESP32 void *lv_malloc_core(size_t size) { auto *ptr = malloc(size); // NOLINT if (ptr == nullptr) { @@ -1052,7 +1053,7 @@ static void *lv_alloc_draw_buf(size_t size, bool internal) { return malloc(size); // NOLINT } -#elif defined(USE_ESP32) +#else static unsigned cap_bits = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; // NOLINT static void *lv_alloc_draw_buf(size_t size, bool internal) { 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 a524fe761f9..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, @@ -21,6 +21,7 @@ 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,12 +43,13 @@ from ..defines import ( STATES, LValidator, add_lv_use, + 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 ( @@ -191,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 @@ -257,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: @@ -323,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( @@ -457,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 @@ -470,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 [] @@ -615,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 65516513a6c..0356e66dac7 100644 --- a/esphome/components/lvgl/widgets/keyboard.py +++ b/esphome/components/lvgl/widgets/keyboard.py @@ -1,17 +1,9 @@ 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 @@ -50,30 +42,32 @@ class KeyboardType(WidgetType): ) def get_uses(self): - return CONF_KEYBOARD, CONF_TEXTAREA, CONF_BUTTONMATRIX, CONF_LABEL + 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 7711e8bfe4f..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() @@ -227,7 +199,6 @@ LIST_ID_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.use_id(lv_list_t)}) ) async def list_add_text_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) - await _wait_list_triggers_completed() async def do_add_text(w: Widget): text = await lv_text.process(config[CONF_TEXT]) @@ -238,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, ) @@ -371,7 +344,6 @@ async def list_add_to_code(config, action_id, template_arg, args): _register_lv_uses(w_type_name, w_conf) _register_dynamic_widget_style_uses(w_conf) widgets = await get_widgets(config) - await _wait_list_triggers_completed() async def do_add(w: Widget): index = None @@ -387,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( @@ -445,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, ()): @@ -505,7 +478,6 @@ LIST_REMOVE_SCHEMA = LIST_ID_SCHEMA.extend( ) async def list_remove_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) - await _wait_list_triggers_completed() async def do_remove(w: Widget): index = await lv_int.process(config[CONF_INDEX]) @@ -526,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( @@ -539,19 +509,15 @@ async def list_remove_to_code(config, action_id, template_arg, args): ) async def list_clear_to_code(config, action_id, template_arg, args): widgets = await get_widgets(config) - await _wait_list_triggers_completed() 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/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/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 2e43eaf7e22..f9b744daf0a 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -27,6 +27,10 @@ CONF_HAS_PULLDOWNS = "has_pulldowns" def check_keys(obj: ConfigType) -> ConfigType: + for ch in obj.get(CONF_KEYS, ""): + if not ch.isascii(): + # Each key is reported as one byte, so only ASCII characters can be key codes + raise cv.Invalid(f"Key code {ch!r} is not an ASCII character") if CONF_KEYS in obj and len(obj[CONF_KEYS]) != len(obj[CONF_ROWS]) * len( obj[CONF_COLUMNS] ): @@ -74,8 +78,11 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(conf[CONF_PIN]) col_pins.append(pin) cg.add(var.set_columns(col_pins)) - if CONF_KEYS in config: - cg.add(var.set_keys(config[CONF_KEYS])) + if (keys := config.get(CONF_KEYS)) is not None: + table = cg.shared_progmem_array( + "matrix_keypad_keys", cg.uint8, list(keys.encode()) + ) + cg.add(var.set_keys(table)) cg.add(var.set_debounce_time(config[CONF_DEBOUNCE_TIME])) if CONF_HAS_DIODES in config: cg.add(var.set_has_diodes(config[CONF_HAS_DIODES])) diff --git a/esphome/components/matrix_keypad/matrix_keypad.cpp b/esphome/components/matrix_keypad/matrix_keypad.cpp index 3b71b50fd87..97641ac2d39 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -58,8 +58,8 @@ void MatrixKeypad::loop() { ESP_LOGD(TAG, "key @ row %d, col %d released", row, col); for (auto &listener : this->listeners_) listener->button_released(row, col); - if (this->pressed_key_ < (int) this->keys_.size()) { - uint8_t keycode = this->keys_[this->pressed_key_]; + if (this->keys_ != nullptr) { + uint8_t keycode = progmem_read_byte(&this->keys_[this->pressed_key_]); ESP_LOGD(TAG, "key '%c' released", keycode); for (auto &listener : this->listeners_) listener->key_released(keycode); @@ -81,8 +81,8 @@ void MatrixKeypad::loop() { ESP_LOGD(TAG, "key @ row %d, col %d pressed", row, col); for (auto &listener : this->listeners_) listener->button_pressed(row, col); - if (key < (int) this->keys_.size()) { - uint8_t keycode = this->keys_[key]; + if (this->keys_ != nullptr) { + uint8_t keycode = progmem_read_byte(&this->keys_[key]); ESP_LOGD(TAG, "key '%c' pressed", keycode); for (auto &trigger : this->key_triggers_) trigger->trigger(keycode); diff --git a/esphome/components/matrix_keypad/matrix_keypad.h b/esphome/components/matrix_keypad/matrix_keypad.h index 8c9acc8e0cb..2384691e32f 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.h +++ b/esphome/components/matrix_keypad/matrix_keypad.h @@ -27,7 +27,8 @@ class MatrixKeypad final : public key_provider::KeyProvider, public Component { void dump_config() override; void set_columns(std::vector pins) { columns_ = std::move(pins); }; void set_rows(std::vector pins) { rows_ = std::move(pins); }; - void set_keys(std::string keys) { keys_ = std::move(keys); }; + /// `keys` is a codegen PROGMEM table with one key code per button. + void set_keys(const uint8_t *keys) { this->keys_ = keys; } void set_debounce_time(uint32_t debounce_time) { debounce_time_ = debounce_time; }; void set_has_diodes(bool has_diodes) { has_diodes_ = has_diodes; }; void set_has_pulldowns(bool has_pulldowns) { has_pulldowns_ = has_pulldowns; }; @@ -38,7 +39,7 @@ class MatrixKeypad final : public key_provider::KeyProvider, public Component { protected: std::vector rows_; std::vector columns_; - std::string keys_; + const uint8_t *keys_{nullptr}; uint32_t debounce_time_ = 0; bool has_diodes_{false}; bool has_pulldowns_{false}; 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..1b621057c67 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( { @@ -58,13 +53,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if voltage_config := config.get(CONF_BATTERY_VOLTAGE): - sens = await sensor.new_sensor(voltage_config) - cg.add(var.set_voltage_sensor(sens)) - - if CONF_BATTERY_LEVEL in config: - sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL]) - cg.add(var.set_battery_remaining_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_remaining_sensor) MAX17043_ACTION_SCHEMA = maybe_simple_id( @@ -74,14 +65,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/max31855/sensor.py b/esphome/components/max31855/sensor.py index a52f45a18f0..3d2a83f8cdc 100644 --- a/esphome/components/max31855/sensor.py +++ b/esphome/components/max31855/sensor.py @@ -41,6 +41,5 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await spi.register_spi_device(var, config) - if CONF_REFERENCE_TEMPERATURE in config: - tc_ref = await sensor.new_sensor(config[CONF_REFERENCE_TEMPERATURE]) - cg.add(var.set_reference_sensor(tc_ref)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_REFERENCE_TEMPERATURE, var.set_reference_sensor) diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 5e45d718999..05abaf886f1 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, @@ -84,7 +79,7 @@ MAX6956_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_MAX6956): cv.use_id(MAX6956), + cv.Required(CONF_MAX6956): pins.use_id_or_address(MAX6956), } ) @@ -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/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 5f4b7276d83..aa606507f0b 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -60,7 +60,7 @@ MCP23016_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_MCP23016): cv.use_id(MCP23016), + cv.Required(CONF_MCP23016): pins.use_id_or_address(MCP23016), } ) diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 755d86e4ea7..a626835518b 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -74,7 +74,7 @@ MCP23XXX_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_MCP23XXX): cv.use_id(MCP23XXXBase), + cv.Required(CONF_MCP23XXX): pins.use_id_or_address(MCP23XXXBase), cv.Optional(CONF_INTERRUPT, default="NO_INTERRUPT"): cv.enum( MCP23XXX_INTERRUPT_MODES, upper=True ), 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.h b/esphome/components/mcp4461/output/mcp4461_output.h index 1052369a744..f0af6037e77 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -33,6 +33,14 @@ 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/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 5542ffaa6c7..75f2e025c76 100644 --- a/esphome/components/mcp9600/sensor.py +++ b/esphome/components/mcp9600/sensor.py @@ -71,12 +71,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_thermocouple_type(config[CONF_THERMOCOUPLE_TYPE])) - if CONF_HOT_JUNCTION in config: - conf = config[CONF_HOT_JUNCTION] - sens = await sensor.new_sensor(conf) - cg.add(var.set_hot_junction(sens)) - - if CONF_COLD_JUNCTION in config: - conf = config[CONF_COLD_JUNCTION] - sens = await sensor.new_sensor(conf) - cg.add(var.set_cold_junction(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_HOT_JUNCTION, var.set_hot_junction) + await sensors(CONF_COLD_JUNCTION, var.set_cold_junction) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 9d1e5853391..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(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 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..053f17765e1 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, @@ -94,13 +77,9 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if co2 := config.get(CONF_CO2): - sens = await sensor.new_sensor(co2) - cg.add(var.set_co2_sensor(sens)) - - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) if ( automatic_baseline_calibration := config.get( @@ -122,33 +101,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 +121,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/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 d7a33524322..770399fef59 100644 --- a/esphome/components/microphone/microphone_source.h +++ b/esphome/components/microphone/microphone_source.h @@ -34,7 +34,7 @@ class MicrophoneSource final { */ public: MicrophoneSource(Microphone *mic, uint8_t bits_per_sample, int32_t gain_factor, bool passive) - : mic_(mic), bits_per_sample_(bits_per_sample), gain_factor_(gain_factor), passive_(passive) {} + : mic_(mic), gain_factor_(gain_factor), passive_(passive), bits_per_sample_(bits_per_sample) {} /// @brief Enables a channel to be processed through the callback. /// @@ -81,11 +81,11 @@ class MicrophoneSource final { std::shared_ptr> processed_samples_; Microphone *mic_; - uint8_t bits_per_sample_; std::bitset<8> channels_; int32_t gain_factor_; bool enabled_{false}; bool passive_; // Only pass audio if ``mic_`` is already running + uint8_t bits_per_sample_; }; } // namespace esphome::microphone 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 07ad02d3afc..da952ab87b0 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -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( @@ -296,15 +214,10 @@ async def to_code(config): cg.add(var.set_custom_presets(config[CONF_CUSTOM_PRESETS])) if CONF_CUSTOM_FAN_MODES in config: cg.add(var.set_custom_fan_modes(config[CONF_CUSTOM_FAN_MODES])) - if CONF_OUTDOOR_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_OUTDOOR_TEMPERATURE]) - cg.add(var.set_outdoor_temperature_sensor(sens)) - if CONF_POWER_USAGE in config: - sens = await sensor.new_sensor(config[CONF_POWER_USAGE]) - cg.add(var.set_power_sensor(sens)) - if CONF_HUMIDITY_SETPOINT in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY_SETPOINT]) - cg.add(var.set_humidity_setpoint_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_OUTDOOR_TEMPERATURE, var.set_outdoor_temperature_sensor) + await sensors(CONF_POWER_USAGE, var.set_power_sensor) + await sensors(CONF_HUMIDITY_SETPOINT, var.set_humidity_setpoint_sensor) # MideaUART uses the Arduino WiFi API for the network-notify frame # (WiFi auto-enables Network via dependency mapping). On ESP-IDF the # library talks to esp_wifi directly, so no library entry is needed. diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 3f73f963277..526afa914b5 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 @@ -644,7 +648,10 @@ class DriverChip: sequence.append((INVON,)) else: sequence.append((INVOFF,)) - if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): + brightness = config.get( + CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS, None) + ) + if brightness is not None: sequence.append((BRIGHTNESS, brightness)) # Add a SLPOUT command if required. if not self.skip_command("SLPOUT"): diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index b23982655a3..6bf94bf364b 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -201,7 +201,8 @@ async def to_code(config: ConfigType) -> None: sequence = model.get_sequence(config) cg.add(var.set_model(config[CONF_MODEL])) - cg.add(var.set_init_sequence(sequence)) + table = cg.shared_progmem_array("mipi_dsi_init_sequence", cg.uint8, sequence) + cg.add(var.set_init_sequence(table, len(sequence))) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) cg.add(var.set_hsync_pulse_width(config[CONF_HSYNC_PULSE_WIDTH])) cg.add(var.set_hsync_back_porch(config[CONF_HSYNC_BACK_PORCH])) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index b6612038b6d..a27be568f25 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -121,20 +121,21 @@ void MipiDsi::setup() { return; } size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = seq[index++]; + uint8_t x = seq[index++]; if (x == DELAY_FLAG) { ESP_LOGD(TAG, "Delay %dms", cmd); delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } @@ -145,7 +146,7 @@ void MipiDsi::setup() { delay(duration); } } - const auto *ptr = vec.data() + index; + const auto *ptr = seq + index; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE char hex_buf[format_hex_pretty_size(MIPI_DSI_MAX_CMD_LOG_BYTES)]; #endif diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 7bf2feb73c5..4243d3116cf 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -55,7 +55,10 @@ class MipiDsi final : public display::Display { void set_vsync_pulse_width(uint16_t vsync_pulse_width) { this->vsync_pulse_width_ = vsync_pulse_width; } void set_vsync_back_porch(uint16_t vsync_back_porch) { this->vsync_back_porch_ = vsync_back_porch; } void set_vsync_front_porch(uint16_t vsync_front_porch) { this->vsync_front_porch_ = vsync_front_porch; } - void set_init_sequence(const std::vector &init_sequence) { this->init_sequence_ = init_sequence; } + void set_init_sequence(const uint8_t *init_sequence, size_t len) { + this->init_sequence_ = init_sequence; + this->init_sequence_len_ = len; + } void set_model(const char *model) { this->model_ = model; } void set_lane_bit_rate(float lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; } void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } @@ -91,7 +94,9 @@ class MipiDsi final : public display::Display { uint16_t vsync_back_porch_ = 10; uint16_t vsync_front_porch_ = 10; const char *model_{"Unknown"}; - std::vector init_sequence_{}; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; float pclk_frequency_ = 16; // in MHz float lane_bit_rate_{1500}; // in Mbps uint8_t lanes_{2}; // 1, 2, 3 or 4 lanes diff --git a/esphome/components/mipi_dsi/models/m5stack.py b/esphome/components/mipi_dsi/models/m5stack.py index 5b07229ec7c..4e847ef6fd9 100644 --- a/esphome/components/mipi_dsi/models/m5stack.py +++ b/esphome/components/mipi_dsi/models/m5stack.py @@ -14,6 +14,8 @@ DsiDriverChip( pclk_frequency="60MHz", lane_bit_rate="730Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0xFF, 0x98, 0x81, 0x01), # Select Page 1 (0xB7, 0x03), # Pad control - 2 lane @@ -67,6 +69,8 @@ TAB5_ST7123 = DsiDriverChip( pclk_frequency="80MHz", lane_bit_rate="960Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0x01,), (0x60, 0x71, 0x23, 0xa2), @@ -118,6 +122,8 @@ DsiDriverChip( pclk_frequency="70MHz", lane_bit_rate="965Mbps", color_order="RGB", + requires={"psram", "pi4ioe5v6408"}, + reset_pin={"pi4ioe5v6408": {"address": 0x43}, "number": 4}, initsequence=[ (0x01,), (0x60, 0x71, 0x21, 0xA2), diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index b91528160e2..71234c2f25b 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -285,8 +285,9 @@ 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) - cg.add(var.set_init_sequence(sequence)) + sequence = model.get_sequence(config, add_reset=True) + table = cg.shared_progmem_array("mipi_rgb_init_sequence", cg.uint8, sequence) + cg.add(var.set_init_sequence(table, len(sequence))) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 3f83da7f803..4928b6f6672 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(); @@ -76,27 +78,28 @@ void MipiRgbSpi::write_data_(uint8_t value) { void MipiRgbSpi::write_init_sequence_() { size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = seq[index++]; + uint8_t x = seq[index++]; if (x == DELAY_FLAG) { ESP_LOGD(TAG, "Delay %dms", cmd); delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { this->mark_failed(LOG_STR("Malformed init sequence")); return; } if (cmd == SLEEP_OUT) { delay(120); // NOLINT } - const auto *ptr = vec.data() + index; + const auto *ptr = seq + index; char hex_buf[format_hex_pretty_size(MIPI_RGB_MAX_CMD_LOG_BYTES)]; ESP_LOGD(TAG, "Write command %02X, length %d, byte(s) %s", cmd, num_args, format_hex_pretty_to(hex_buf, ptr, num_args, '.')); @@ -109,7 +112,6 @@ void MipiRgbSpi::write_init_sequence_() { } } // this->spi_teardown(); // SPI not needed after this - this->init_sequence_.clear(); delay(10); } diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index f528943c1b5..8b29e273abd 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -109,7 +109,10 @@ class MipiRgbSpi final : public MipiRgb, public: MipiRgbSpi(int width, int height) : MipiRgb(width, height) {} - void set_init_sequence(const std::vector &init_sequence) { this->init_sequence_ = init_sequence; } + void set_init_sequence(const uint8_t *init_sequence, size_t len) { + this->init_sequence_ = init_sequence; + this->init_sequence_len_ = len; + } void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } void setup() override; @@ -120,7 +123,9 @@ class MipiRgbSpi final : public MipiRgb, void dump_config() override; GPIOPin *dc_pin_{nullptr}; - std::vector init_sequence_; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; }; #endif 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/display.py b/esphome/components/mipi_spi/display.py index e8b54da5c79..ff49979a244 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -225,10 +225,10 @@ def model_schema(config: ConfigType) -> cv.All | cv.Schema: ) .extend({model.option(x): cv.boolean for x in other_options}) ) - if brightness := model.get_default(CONF_BRIGHTNESS): + if model.get_default(CONF_BRIGHTNESS, None) is not None or model.name == "CUSTOM": schema = schema.extend( { - cv.Optional(CONF_BRIGHTNESS, default=brightness): cv.int_range( + model.option(CONF_BRIGHTNESS, cv.UNDEFINED): cv.int_range( 0, 0xFF, min_included=True, max_included=True ), } @@ -401,7 +401,8 @@ async def to_code(config: ConfigType) -> None: init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) - cg.add(var.set_init_sequence(init_sequence)) + table = cg.shared_progmem_array("mipi_spi_init_sequence", cg.uint8, init_sequence) + cg.add(var.set_init_sequence(table, len(init_sequence))) cg.add(var.set_model(config[CONF_MODEL])) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] diff --git a/esphome/components/mipi_spi/light/__init__.py b/esphome/components/mipi_spi/light/__init__.py new file mode 100644 index 00000000000..aec5d9b4fdf --- /dev/null +++ b/esphome/components/mipi_spi/light/__init__.py @@ -0,0 +1,68 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.const import ( + CONF_BRIGHTNESS, + CONF_DISPLAY_ID, + CONF_GAMMA_CORRECT, + CONF_MAX_BRIGHTNESS, + CONF_MIN_BRIGHTNESS, + CONF_MODEL, + CONF_OUTPUT_ID, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from ..display import MipiSpi, mipi_spi_ns + +MipiSpiLight = mipi_spi_ns.class_("MipiSpiLight", light.LightOutput) + + +def _validate_brightness_range(config: ConfigType) -> ConfigType: + if config[CONF_MIN_BRIGHTNESS] >= config[CONF_MAX_BRIGHTNESS]: + raise cv.Invalid( + f"'{CONF_MIN_BRIGHTNESS}' must be less than '{CONF_MAX_BRIGHTNESS}'" + ) + return config + + +CONFIG_SCHEMA = cv.All( + light.BRIGHTNESS_ONLY_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MipiSpiLight), + cv.GenerateID(CONF_DISPLAY_ID): cv.use_id(MipiSpi), + cv.Optional(CONF_GAMMA_CORRECT, default=1.0): cv.positive_float, + cv.Optional(CONF_MIN_BRIGHTNESS, default=0): cv.int_range(0, 255), + cv.Optional(CONF_MAX_BRIGHTNESS, default=255): cv.int_range(0, 255), + } + ), + _validate_brightness_range, +) + + +def _final_validate(config: ConfigType) -> None: + full_config = fv.full_config.get() + display_path = full_config.get_path_for_id(config[CONF_DISPLAY_ID])[:-1] + display_config = full_config.get_config_for_path(display_path) + if CONF_BRIGHTNESS not in display_config: + model = display_config[CONF_MODEL] + if model == "CUSTOM": + raise cv.Invalid( + f"The '{CONF_BRIGHTNESS}' option must be set in the display config" + ) + raise cv.Invalid( + f"Display model {model} does not support setting brightness with an SPI command" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_MIN_BRIGHTNESS], + config[CONF_MAX_BRIGHTNESS], + ) + await light.register_light(var, config) + await cg.register_parented(var, config[CONF_DISPLAY_ID]) diff --git a/esphome/components/mipi_spi/light/mipi_spi_light.h b/esphome/components/mipi_spi/light/mipi_spi_light.h new file mode 100644 index 00000000000..30bbdcd8ee0 --- /dev/null +++ b/esphome/components/mipi_spi/light/mipi_spi_light.h @@ -0,0 +1,36 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/components/light/light_state.h" +#include "esphome/components/mipi_spi/mipi_spi.h" +#include "esphome/core/helpers.h" + +namespace esphome::mipi_spi { + +// Brightness-only light that sets the display brightness with an SPI command. +// Brightness is scaled into [min_brightness, max_brightness]; off writes min_brightness. +class MipiSpiLight final : public light::LightOutput, public Parented { + public: + MipiSpiLight(uint8_t min_brightness, uint8_t max_brightness) + : min_brightness_(min_brightness), max_brightness_(max_brightness) {} + + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::BRIGHTNESS}); + return traits; + } + + void write_state(light::LightState *state) override { + float brightness; + state->current_values_as_brightness(&brightness); + this->parent_->set_brightness( + this->min_brightness_ + + static_cast(roundf(brightness * (this->max_brightness_ - this->min_brightness_)))); + } + + protected: + uint8_t min_brightness_; + uint8_t max_brightness_; +}; + +} // namespace esphome::mipi_spi diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 550e1998bb5..48a2cafbdd3 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -5,6 +5,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/components/display/display.h" #include "esphome/components/display/display_color_utils.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" namespace esphome::mipi_spi { @@ -71,6 +72,12 @@ void internal_dump_config(const char *model, int width, int height, int offset_w GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, bool has_hardware_rotation); +// Lets a light set the display brightness without knowing the display's template parameters. +class MipiSpiBrightness { + public: + virtual void set_brightness(uint8_t brightness) = 0; +}; + /** * Base class for MIPI SPI displays. * All the methods are defined here in the header file, as it is not possible to define templated methods in a cpp file. @@ -93,6 +100,7 @@ template class MipiSpi : public display::Display, + public MipiSpiBrightness, public spi::SPIDevice { public: @@ -107,9 +115,11 @@ class MipiSpi : public display::Display, this->invert_colors_ = invert_colors; this->reset_params_(); } - void set_brightness(uint8_t brightness) { + void set_brightness(uint8_t brightness) override { this->brightness_ = brightness; - this->reset_params_(); + // Before setup the stored value is applied by reset_params_() + if (this->is_ready()) + this->write_command_(BRIGHTNESS, brightness); } void set_rotation(display::DisplayRotation rotation) override { this->rotation_ = rotation; @@ -133,7 +143,10 @@ class MipiSpi : public display::Display, return HEIGHT; } - void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } + void set_init_sequence(const uint8_t *sequence, size_t len) { + this->init_sequence_ = sequence; + this->init_sequence_len_ = len; + } // reset the display, and write the init sequence void setup() override { @@ -159,15 +172,16 @@ class MipiSpi : public display::Display, // need to know when the display is ready for SLPOUT command - will be 120ms after reset auto when = millis() + 120; size_t index = 0; - auto &vec = this->init_sequence_; - while (index != vec.size()) { - if (vec.size() - index < 2) { + const uint8_t *seq = this->init_sequence_; + const size_t len = this->init_sequence_len_; + while (index != len) { + if (len - index < 2) { esph_log_e(TAG, "Malformed init sequence"); this->mark_failed(); return; } - uint8_t cmd = vec[index++]; - uint8_t x = vec[index++]; + uint8_t cmd = progmem_read_byte(seq + index++); + uint8_t x = progmem_read_byte(seq + index++); if (x == DELAY_FLAG) { if (cmd == 0) { cmd = clamp_at_least((int) (when - millis()), 0); @@ -176,19 +190,21 @@ class MipiSpi : public display::Display, delay(cmd); } else { uint8_t num_args = x & 0x7F; - if (vec.size() - index < num_args) { + if (len - index < num_args) { esph_log_e(TAG, "Malformed init sequence"); this->mark_failed(); return; } - const auto *ptr = vec.data() + index; - this->write_command_(cmd, ptr, num_args); + // The sequence is in flash, which SPI DMA (and ESP8266 byte loads) cannot read + uint8_t args[0x80]; + progmem_memcpy(args, seq + index, num_args); + this->write_command_(cmd, args, num_args); index += num_args; } } this->reset_params_(); - // init sequence no longer needed - this->init_sequence_.clear(); + // Marks init as done, so later commands log at verbose level instead of debug + this->init_sequence_len_ = 0; } // Drawing operations @@ -236,7 +252,7 @@ class MipiSpi : public display::Display, void write_command_(uint8_t cmd, const uint8_t *bytes, size_t len) { char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)]; // Don't spam the log after setup - if (this->init_sequence_.empty()) { + if (this->init_sequence_len_ == 0) { esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); } else { esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); @@ -246,20 +262,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 +285,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]); @@ -486,7 +502,9 @@ class MipiSpi : public display::Display, bool invert_colors_{}; optional brightness_{}; const char *model_{"Unknown"}; - std::vector init_sequence_{}; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; }; /** diff --git a/esphome/components/mipi_spi/models/spd2010.py b/esphome/components/mipi_spi/models/spd2010.py new file mode 100644 index 00000000000..b5893f179a2 --- /dev/null +++ b/esphome/components/mipi_spi/models/spd2010.py @@ -0,0 +1,418 @@ +# SPDX-FileCopyrightText: 2023 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +# Adapted from esp_lcd_spd2010 2.0.0~1 for ESPHome. +# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_spd2010 + +from esphome.components.mipi import MODE_RGB, DriverChip, delay +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y + +# fmt: off +DriverChip( + "WAVESHARE-ESP32-S3-TOUCH-LCD-1.46", + width=412, + height=412, + bus_mode=TYPE_QUAD, + spi_mode="MODE0", + data_rate="40MHz", + cs_pin=21, + reset_pin={"pca9554": None, "number": 1}, + color_order=MODE_RGB, + draw_rounding=4, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, + use_axis_flips=True, + reset_delay=20, + requires={"pca9554"}, + initsequence=( + delay(100), + (0xFF, 0x20, 0x10, 0x10), + (0x0C, 0x11), + (0x10, 0x02), + (0x11, 0x11), + (0x15, 0x42), + (0x16, 0x11), + (0x1A, 0x02), + (0x1B, 0x11), + (0x61, 0x80), + (0x62, 0x80), + (0x54, 0x44), + (0x58, 0x88), + (0x5C, 0xCC), + (0x20, 0x80), + (0x21, 0x81), + (0x22, 0x31), + (0x23, 0x20), + (0x24, 0x11), + (0x25, 0x11), + (0x26, 0x12), + (0x27, 0x12), + (0x30, 0x80), + (0x31, 0x81), + (0x32, 0x31), + (0x33, 0x20), + (0x34, 0x11), + (0x35, 0x11), + (0x36, 0x12), + (0x37, 0x12), + (0x41, 0x11), + (0x42, 0x22), + (0x43, 0x33), + (0x49, 0x11), + (0x4A, 0x22), + (0x4B, 0x33), + (0xFF, 0x20, 0x10, 0x15), + (0x00, 0x00), + (0x01, 0x00), + (0x02, 0x00), + (0x03, 0x00), + (0x04, 0x10), + (0x05, 0x0C), + (0x06, 0x23), + (0x07, 0x22), + (0x08, 0x21), + (0x09, 0x20), + (0x0A, 0x33), + (0x0B, 0x32), + (0x0C, 0x34), + (0x0D, 0x35), + (0x0E, 0x01), + (0x0F, 0x01), + (0x20, 0x00), + (0x21, 0x00), + (0x22, 0x00), + (0x23, 0x00), + (0x24, 0x0C), + (0x25, 0x10), + (0x26, 0x20), + (0x27, 0x21), + (0x28, 0x22), + (0x29, 0x23), + (0x2A, 0x33), + (0x2B, 0x32), + (0x2C, 0x34), + (0x2D, 0x35), + (0x2E, 0x01), + (0x2F, 0x01), + (0xFF, 0x20, 0x10, 0x16), + (0x00, 0x00), + (0x01, 0x00), + (0x02, 0x00), + (0x03, 0x00), + (0x04, 0x08), + (0x05, 0x04), + (0x06, 0x19), + (0x07, 0x18), + (0x08, 0x17), + (0x09, 0x16), + (0x0A, 0x33), + (0x0B, 0x32), + (0x0C, 0x34), + (0x0D, 0x35), + (0x0E, 0x01), + (0x0F, 0x01), + (0x20, 0x00), + (0x21, 0x00), + (0x22, 0x00), + (0x23, 0x00), + (0x24, 0x04), + (0x25, 0x08), + (0x26, 0x16), + (0x27, 0x17), + (0x28, 0x18), + (0x29, 0x19), + (0x2A, 0x33), + (0x2B, 0x32), + (0x2C, 0x34), + (0x2D, 0x35), + (0x2E, 0x01), + (0x2F, 0x01), + (0xFF, 0x20, 0x10, 0x12), + (0x00, 0x99), + (0x2A, 0x28), + (0x2B, 0x0F), + (0x2C, 0x16), + (0x2D, 0x28), + (0x2E, 0x0F), + (0xFF, 0x20, 0x10, 0xA0), + (0x08, 0xDC), + (0xFF, 0x20, 0x10, 0x45), + (0x01, 0x9C), + (0x03, 0x9C), + (0xFF, 0x20, 0x10, 0x42), + (0x05, 0x2C), + (0xFF, 0x20, 0x10, 0x11), + (0x50, 0x01), + (0xFF, 0x20, 0x10, 0x40), + (0x86, 0x00), + (0xFF, 0x20, 0x10, 0x12), + (0x0D, 0x66), + (0xFF, 0x20, 0x10, 0x17), + (0x39, 0x3C), + (0xFF, 0x20, 0x10, 0x31), + (0x38, 0x03), + (0x39, 0xF0), + (0x36, 0x03), + (0x37, 0xE8), + (0x34, 0x03), + (0x35, 0xCF), + (0x32, 0x03), + (0x33, 0xBA), + (0x30, 0x03), + (0x31, 0xA2), + (0x2E, 0x03), + (0x2F, 0x95), + (0x2C, 0x03), + (0x2D, 0x7E), + (0x2A, 0x03), + (0x2B, 0x62), + (0x28, 0x03), + (0x29, 0x44), + (0x26, 0x02), + (0x27, 0xFC), + (0x24, 0x02), + (0x25, 0xD0), + (0x22, 0x02), + (0x23, 0x98), + (0x20, 0x02), + (0x21, 0x6F), + (0x1E, 0x02), + (0x1F, 0x32), + (0x1C, 0x01), + (0x1D, 0xF6), + (0x1A, 0x01), + (0x1B, 0xB8), + (0x18, 0x01), + (0x19, 0x6E), + (0x16, 0x01), + (0x17, 0x41), + (0x14, 0x00), + (0x15, 0xFD), + (0x12, 0x00), + (0x13, 0xCF), + (0x10, 0x00), + (0x11, 0x98), + (0x0E, 0x00), + (0x0F, 0x89), + (0x0C, 0x00), + (0x0D, 0x79), + (0x0A, 0x00), + (0x0B, 0x67), + (0x08, 0x00), + (0x09, 0x55), + (0x06, 0x00), + (0x07, 0x3F), + (0x04, 0x00), + (0x05, 0x28), + (0x02, 0x00), + (0x03, 0x0E), + (0xFF, 0x20, 0x10, 0x32), + (0x38, 0x03), + (0x39, 0xF0), + (0x36, 0x03), + (0x37, 0xE8), + (0x34, 0x03), + (0x35, 0xCF), + (0x32, 0x03), + (0x33, 0xBA), + (0x30, 0x03), + (0x31, 0xA2), + (0x2E, 0x03), + (0x2F, 0x95), + (0x2C, 0x03), + (0x2D, 0x7E), + (0x2A, 0x03), + (0x2B, 0x62), + (0x28, 0x03), + (0x29, 0x44), + (0x26, 0x02), + (0x27, 0xFC), + (0x24, 0x02), + (0x25, 0xD0), + (0x22, 0x02), + (0x23, 0x98), + (0x20, 0x02), + (0x21, 0x6F), + (0x1E, 0x02), + (0x1F, 0x32), + (0x1C, 0x01), + (0x1D, 0xF6), + (0x1A, 0x01), + (0x1B, 0xB8), + (0x18, 0x01), + (0x19, 0x6E), + (0x16, 0x01), + (0x17, 0x41), + (0x14, 0x00), + (0x15, 0xFD), + (0x12, 0x00), + (0x13, 0xCF), + (0x10, 0x00), + (0x11, 0x98), + (0x0E, 0x00), + (0x0F, 0x89), + (0x0C, 0x00), + (0x0D, 0x79), + (0x0A, 0x00), + (0x0B, 0x67), + (0x08, 0x00), + (0x09, 0x55), + (0x06, 0x00), + (0x07, 0x3F), + (0x04, 0x00), + (0x05, 0x28), + (0x02, 0x00), + (0x03, 0x0E), + (0xFF, 0x20, 0x10, 0x11), + (0x60, 0x01), + (0x65, 0x03), + (0x66, 0x38), + (0x67, 0x04), + (0x68, 0x34), + (0x69, 0x03), + (0x61, 0x03), + (0x62, 0x38), + (0x63, 0x04), + (0x64, 0x34), + (0x0A, 0x11), + (0x0B, 0x20), + (0x0C, 0x20), + (0x55, 0x06), + (0xFF, 0x20, 0x10, 0x42), + (0x05, 0x3D), + (0x06, 0x03), + (0xFF, 0x20, 0x10, 0x12), + (0x1F, 0xDC), + (0xFF, 0x20, 0x10, 0x17), + (0x11, 0xAA), + (0x16, 0x12), + (0x0B, 0xC3), + (0x10, 0x0E), + (0x14, 0xAA), + (0x18, 0xA0), + (0x1A, 0x80), + (0x1F, 0x80), + (0xFF, 0x20, 0x10, 0x11), + (0x30, 0xEE), + (0xFF, 0x20, 0x10, 0x12), + (0x15, 0x0F), + (0xFF, 0x20, 0x10, 0x2D), + (0x01, 0x3E), + (0xFF, 0x20, 0x10, 0x40), + (0x83, 0xC4), + (0xFF, 0x20, 0x10, 0x12), + (0x00, 0xCC), + (0x36, 0xA0), + (0x2A, 0x2D), + (0x2B, 0x1E), + (0x2C, 0x26), + (0x2D, 0x2D), + (0x2E, 0x1E), + (0x1F, 0xE6), + (0xFF, 0x20, 0x10, 0xA0), + (0x08, 0xE6), + (0xFF, 0x20, 0x10, 0x12), + (0x10, 0x0F), + (0xFF, 0x20, 0x10, 0x18), + (0x01, 0x01), + (0x00, 0x1E), + (0xFF, 0x20, 0x10, 0x43), + (0x03, 0x04), + (0xFF, 0x20, 0x10, 0x18), + (0x3A, 0x01), + (0xFF, 0x20, 0x10, 0x50), + (0x05, 0x08), + (0xFF, 0x20, 0x10, 0x50), + (0x00, 0xA6), + (0x01, 0xA6), + (0xFF, 0x20, 0x10, 0x50), + (0x08, 0x55), + (0xFF, 0x20, 0x10, 0x10), + (0x0B, 0x43), + (0x0C, 0x12), + (0x10, 0x01), + (0x11, 0x12), + (0x15, 0x00), + (0x16, 0x00), + (0x1A, 0x00), + (0x1B, 0x00), + (0x61, 0x00), + (0x62, 0x00), + (0x51, 0x11), + (0x55, 0x55), + (0x58, 0x00), + (0x5C, 0x00), + (0xFF, 0x20, 0x10, 0x10), + (0x20, 0x81), + (0x21, 0x82), + (0x22, 0x72), + (0x30, 0x00), + (0x31, 0x00), + (0x32, 0x00), + (0xFF, 0x20, 0x10, 0x10), + (0x44, 0x44), + (0x45, 0x55), + (0x46, 0x66), + (0x47, 0x77), + (0x49, 0x00), + (0x4A, 0x00), + (0x4B, 0x00), + (0xFF, 0x20, 0x10, 0x17), + (0x37, 0x00), + (0xFF, 0x20, 0x10, 0x15), + (0x04, 0x08), + (0x05, 0x04), + (0x06, 0x1C), + (0x07, 0x1A), + (0x08, 0x18), + (0x09, 0x16), + (0x24, 0x05), + (0x25, 0x09), + (0x26, 0x17), + (0x27, 0x19), + (0x28, 0x1B), + (0x29, 0x1D), + (0xFF, 0x20, 0x10, 0x16), + (0x04, 0x09), + (0x05, 0x05), + (0x06, 0x1D), + (0x07, 0x1B), + (0x08, 0x19), + (0x09, 0x17), + (0x24, 0x04), + (0x25, 0x08), + (0x26, 0x16), + (0x27, 0x18), + (0x28, 0x1A), + (0x29, 0x1C), + (0xFF, 0x20, 0x10, 0x18), + (0x1F, 0x02), + (0xFF, 0x20, 0x10, 0x11), + (0x15, 0x99), + (0x16, 0x99), + (0x1C, 0x88), + (0x1D, 0x88), + (0x1E, 0x88), + (0x13, 0xF0), + (0x14, 0x34), + (0xFF, 0x20, 0x10, 0x12), + (0x12, 0x89), + (0x06, 0x06), + (0x18, 0x00), + (0xFF, 0x20, 0x10, 0x11), + (0x0A, 0x00), + (0x0B, 0xF0), + (0x0C, 0xF0), + (0x6A, 0x10), + (0xFF, 0x20, 0x10, 0x11), + (0x08, 0x70), + (0x09, 0x00), + (0xFF, 0x20, 0x10, 0x00), + (0x35, 0x00), + (0xFF, 0x20, 0x10, 0x12), + (0x21, 0x70), + (0xFF, 0x20, 0x10, 0x2D), + (0x02, 0x00), + (0xFF, 0x20, 0x10, 0x00), + ), +) +# fmt: on 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 26619f35a76..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,9 +142,8 @@ 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), @@ -162,20 +155,8 @@ async def to_code(config: ConfigType) -> None: ), } ), - 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.h b/esphome/components/mixer/speaker/mixer_speaker.h index 494443d6951..04b0cb94f40 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -107,12 +107,12 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool stop_gracefully_{false}; bool pause_state_{false}; + std::atomic has_contributed_{false}; // Tracks if source has contributed during this session 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 - std::atomic has_contributed_{false}; // Tracks if source has contributed during this session EventGroupHandle_t event_group_{nullptr}; uint32_t stopping_start_ms_{0}; 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.cpp b/esphome/components/mk2pvrouter/mk2pvrouter.cpp index 0c0476fb11e..f8aa1003bc4 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -1,5 +1,6 @@ #include "mk2pvrouter.h" #include "esphome/core/log.h" +#include #include namespace esphome::mk2pvrouter { @@ -48,7 +49,7 @@ uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { // Verifies the CRC of a group against its trailing CRC byte. bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { const auto grp_len = grp_end - grp; - if (grp_len < static_cast(CRC_SUFFIX_LEN)) { + if (grp_len < static_cast(CRC_SUFFIX_LEN)) { ESP_LOGE(TAG, "Empty or too short group"); return false; } 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/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index 59bdffc1140..4ff42c7d9c7 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -139,21 +139,14 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_temperature_compensation(config[CONF_TEMPERATURE_COMPENSATION])) cg.add(var.set_hallconf(config[CONF_HALLCONF])) - if CONF_X_AXIS in config: - sens = await sensor.new_sensor(config[CONF_X_AXIS]) - cg.add(var.set_x_sensor(sens)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_X_AXIS, var.set_x_sensor): cg.add(var.set_resolution(0, RESOLUTION[config[CONF_X_AXIS][CONF_RESOLUTION]])) - if CONF_Y_AXIS in config: - sens = await sensor.new_sensor(config[CONF_Y_AXIS]) - cg.add(var.set_y_sensor(sens)) + if await sensors(CONF_Y_AXIS, var.set_y_sensor): cg.add(var.set_resolution(1, RESOLUTION[config[CONF_Y_AXIS][CONF_RESOLUTION]])) - if CONF_Z_AXIS in config: - sens = await sensor.new_sensor(config[CONF_Z_AXIS]) - cg.add(var.set_z_sensor(sens)) + if await sensors(CONF_Z_AXIS, var.set_z_sensor): cg.add(var.set_resolution(2, RESOLUTION[config[CONF_Z_AXIS][CONF_RESOLUTION]])) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_t_sensor(sens)) + if await sensors(CONF_TEMPERATURE, var.set_t_sensor): cg.add(var.set_t_oversampling(config[CONF_TEMPERATURE][CONF_OVERSAMPLING])) if CONF_DRDY_PIN in config: pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) diff --git a/esphome/components/mlx90614/sensor.py b/esphome/components/mlx90614/sensor.py index 0cf9b95dde8..e818ea708d0 100644 --- a/esphome/components/mlx90614/sensor.py +++ b/esphome/components/mlx90614/sensor.py @@ -53,12 +53,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_AMBIENT in config: - sens = await sensor.new_sensor(config[CONF_AMBIENT]) - cg.add(var.set_ambient_sensor(sens)) - - if CONF_OBJECT in config: - sens = await sensor.new_sensor(config[CONF_OBJECT]) - cg.add(var.set_object_sensor(sens)) - + sensors = sensor.sub_sensors(config) + await sensors(CONF_AMBIENT, var.set_ambient_sensor) + if await sensors(CONF_OBJECT, var.set_object_sensor): cg.add(var.set_emissivity(config[CONF_OBJECT][CONF_EMISSIVITY])) 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/mmc5983/sensor.py b/esphome/components/mmc5983/sensor.py index 797181690f4..48c532b0c67 100644 --- a/esphome/components/mmc5983/sensor.py +++ b/esphome/components/mmc5983/sensor.py @@ -45,12 +45,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if x_config := config.get(CONF_FIELD_STRENGTH_X): - sens = await sensor.new_sensor(x_config) - cg.add(var.set_x_sensor(sens)) - if y_config := config.get(CONF_FIELD_STRENGTH_Y): - sens = await sensor.new_sensor(y_config) - cg.add(var.set_y_sensor(sens)) - if z_config := config.get(CONF_FIELD_STRENGTH_Z): - sens = await sensor.new_sensor(z_config) - cg.add(var.set_z_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) diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index fe937587261..cad2781cc17 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -7,6 +7,7 @@ from typing import Any, Literal, NamedTuple from esphome import pins import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_ROLE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj @@ -45,13 +46,18 @@ 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" CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The client hub takes these times as 16-bit milliseconds; a Modbus timeout is far shorter anyway. +_HUB_TIME_PERIOD = cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(milliseconds=65535)), +) + # 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. @@ -273,12 +279,8 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusClient), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - cv.Optional( - CONF_SEND_WAIT_TIME, default="2000ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TURNAROUND_TIME, default="600ms" - ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_SEND_WAIT_TIME, default="2000ms"): _HUB_TIME_PERIOD, + cv.Optional(CONF_TURNAROUND_TIME, default="600ms"): _HUB_TIME_PERIOD, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 037901a8733..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; @@ -1213,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"); } @@ -1234,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/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..741996cbb83 100644 --- a/esphome/components/motion/__init__.py +++ b/esphome/components/motion/__init__.py @@ -1,13 +1,18 @@ from collections.abc import Callable +import logging import re from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS +from esphome.const import CONF_ID, CONF_ON_ERROR, CONF_ON_SUCCESS, CONF_UPDATE_INTERVAL +from esphome.core import ID from esphome.cpp_generator import MockObj, MockObjClass +import esphome.final_validate as fv from esphome.helpers import fnv1_hash_object_id +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@esphome/core"] DOMAIN = "motion" @@ -25,7 +30,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" @@ -38,6 +42,48 @@ SENSOR_SCHEMA = cv.Schema( _AXIS_REGEX = re.compile(r"^[+-]?[xyz]$", re.IGNORECASE) +# Fast motion patterns (shakes, free-fall, sudden movement) need frequent samples +# to be detected reliably; a slower parent update_interval makes them likely to be +# missed between polls. +MAX_RECOMMENDED_UPDATE_INTERVAL_MS = 100 + + +def get_motion_config(motion_id: ID) -> dict: + """Look up the (already-validated) config of the motion hub referenced by motion_id.""" + full_config = fv.full_config.get() + motion_path = full_config.get_path_for_id(motion_id)[:-1] + return full_config.get_config_for_path(motion_path) + + +def check_update_interval(motion_id: ID, feature_name: str) -> None: + """Warn if the parent motion component polls too slowly for fast motion detection.""" + motion_config = get_motion_config(motion_id) + update_interval = motion_config[CONF_UPDATE_INTERVAL] + if update_interval.total_milliseconds > MAX_RECOMMENDED_UPDATE_INTERVAL_MS: + _LOGGER.warning( + "Motion component '%s' has update_interval %s, but %s detection " + "works best with an update_interval of %dms or less.", + motion_id, + update_interval, + feature_name, + MAX_RECOMMENDED_UPDATE_INTERVAL_MS, + ) + + +def check_has_accelerometer( + motion_id: ID, feature_name: str, path: list[str] | None = None +) -> None: + """Raise if the parent motion device does not measure acceleration. + + `path` locates the error within the calling entity's config. + """ + motion_config = get_motion_config(motion_id) + if not motion_config.get(KEY_ACCELEROMETER, False): + raise cv.Invalid( + f"The motion device does not measure acceleration, required for {feature_name}", + path=path, + ) + def _axis_map(config: dict) -> dict: errors = [] @@ -207,15 +253,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/binary_sensor/__init__.py b/esphome/components/motion/binary_sensor/__init__.py new file mode 100644 index 00000000000..0a3876c8ab2 --- /dev/null +++ b/esphome/components/motion/binary_sensor/__init__.py @@ -0,0 +1,120 @@ +from collections.abc import Callable +import math +from typing import Any + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_ID, CONF_THRESHOLD, CONF_TYPE +from esphome.types import ConfigType + +from .. import ( + CONF_MOTION_ID, + MotionComponent, + check_has_accelerometer, + check_update_interval, + motion_ns, +) + +DEPENDENCIES = ["motion"] + +MotionBinarySensor = motion_ns.class_( + "MotionBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +MotionBinarySensorType = motion_ns.enum("MotionBinarySensorType") + +SENSOR_TYPES = { + "face_up": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_UP, + "face_down": MotionBinarySensorType.MOTION_BINARY_SENSOR_FACE_DOWN, + "free_fall": MotionBinarySensorType.MOTION_BINARY_SENSOR_FREE_FALL, + "moving": MotionBinarySensorType.MOTION_BINARY_SENSOR_MOVING, +} + +# face_up / face_down configure their threshold as a maximum tilt angle in degrees; +# the C++ side compares against the cosine of that angle. +ANGLE_THRESHOLD_TYPES = ("face_up", "face_down") + + +def _binary_sensor_schema( + default_threshold: float, + threshold_validator: Callable[[Any], Any], + default_duration: str | None = None, +) -> cv.Schema: + schema = ( + binary_sensor.binary_sensor_schema(MotionBinarySensor) + .extend( + { + cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent), + cv.Optional( + CONF_THRESHOLD, default=default_threshold + ): threshold_validator, + } + ) + .extend(cv.COMPONENT_SCHEMA) + ) + + if default_duration is not None: + schema = schema.extend( + { + cv.Optional( + CONF_DURATION, default=default_duration + ): cv.positive_time_period_milliseconds, + } + ) + return schema + + +# Tilt angle in degrees, from horizontal, within which the device counts as face up/down. +# 0 is excluded: cos(0) == 1.0 would make the C++ comparison always false, so +# face_up/face_down would never trigger. +_angle_threshold = cv.float_range(min=0.0, max=90.0, min_included=False) +# 0 is excluded: free_fall would never trigger and moving would always be on. +_positive_threshold = cv.float_range(min=0.0, min_included=False) + +CONFIG_SCHEMA = cv.typed_schema( + { + "face_up": _binary_sensor_schema(30.0, _angle_threshold), + "face_down": _binary_sensor_schema(30.0, _angle_threshold), + "free_fall": _binary_sensor_schema(0.15, _positive_threshold, "100ms"), + "moving": _binary_sensor_schema(0.05, _positive_threshold, "2s"), + } +) + +# These types detect brief motion events, so they need frequent samples; +# face_up/face_down track a steady orientation and aren't time-sensitive. +_FAST_DETECTION_TYPES = ("free_fall", "moving") + +# face_up/face_down/free_fall are entirely accelerometer-driven; "moving" is exempt +# since it detects motion from either the accelerometer or the gyroscope. +_ACCEL_ONLY_TYPES = ("face_up", "face_down", "free_fall") + + +def _final_validate(config: dict) -> None: + sensor_type = config[CONF_TYPE] + if sensor_type in _FAST_DETECTION_TYPES: + check_update_interval(config[CONF_MOTION_ID], sensor_type.replace("_", "-")) + if sensor_type in _ACCEL_ONLY_TYPES: + check_has_accelerometer( + config[CONF_MOTION_ID], sensor_type.replace("_", "-"), path=[CONF_TYPE] + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + sensor_type = config[CONF_TYPE] + parent = await cg.get_variable(config[CONF_MOTION_ID]) + + var = cg.new_Pvariable(config[CONF_ID], parent, SENSOR_TYPES[sensor_type]) + await binary_sensor.register_binary_sensor(var, config) + await cg.register_component(var, config) + + threshold = config[CONF_THRESHOLD] + if sensor_type in ANGLE_THRESHOLD_TYPES: + # Convert the configured tilt angle (degrees) to the cosine the C++ side expects. + threshold = round(math.cos(math.radians(threshold)), 6) + cg.add(var.set_threshold(threshold)) + if (duration := config.get(CONF_DURATION)) is not None: + cg.add(var.set_duration(duration)) diff --git a/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp b/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp new file mode 100644 index 00000000000..f663b41adbf --- /dev/null +++ b/esphome/components/motion/binary_sensor/motion_binary_sensor.cpp @@ -0,0 +1,183 @@ +#include "motion_binary_sensor.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/core/application.h" +#include "esphome/core/progmem.h" + +namespace esphome::motion { + +static const char *const TAG = "motion.binary_sensor"; + +// Thresholds used to decide the device is at rest for face_up / face_down detection. +// While moving (shaking, being picked up) the orientation reading is dominated by +// linear acceleration and cannot be trusted, so those sensors block (hold) instead. +static constexpr float STILL_ACCEL_TOLERANCE = 0.12f; // max deviation of |accel| from 1g, in g +static constexpr float STILL_GYRO_THRESHOLD = 15.0f; // max angular rate magnitude, in °/s +static constexpr float GYRO_THRESHOLD_SCALE = 50.0f; // arbitrary gyro threshold scale, in °/s per g of acceleration + +MotionBinarySensor::MotionBinarySensor(MotionComponent *parent, MotionBinarySensorType type) + : parent_(parent), type_(type) {} + +bool MotionBinarySensor::is_stationary(const MotionData &data) { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) + return false; + + // Total acceleration must be close to 1g; a larger deviation means the device is + // being accelerated (shaken / moved) and the gravity direction cannot be trusted. + float mag = std::sqrt(ax * ax + ay * ay + az * az); + if (std::fabs(mag - 1.0f) > STILL_ACCEL_TOLERANCE) + return false; + + // If a gyroscope is present, also require the angular rate to be low. + float gx = data.angular_rate[X_AXIS]; + float gy = data.angular_rate[Y_AXIS]; + float gz = data.angular_rate[Z_AXIS]; + if (!std::isnan(gx) && !std::isnan(gy) && !std::isnan(gz)) { + float gmag = std::sqrt(gx * gx + gy * gy + gz * gz); + if (gmag > STILL_GYRO_THRESHOLD) + return false; + } + return true; +} + +void MotionBinarySensor::setup() { + this->parent_->add_listener([this](MotionData const &data) { this->process_motion_data_(data); }); + this->publish_state(false); // default to false until the first update +} + +PROGMEM_STRING_TABLE(MotionBinarySensorTypeNames, "face_up", "face_down", "free_fall", "moving", "unknown"); + +void MotionBinarySensor::dump_config() { + LOG_BINARY_SENSOR("", "Motion Binary Sensor", this); + ESP_LOGCONFIG( + TAG, + " Type: %s\n" + " Threshold: %.3f\n" + " Duration: %" PRIu32 " ms", + LOG_STR_ARG(MotionBinarySensorTypeNames::get_log_str(this->type_, MotionBinarySensorTypeNames::LAST_INDEX)), + this->threshold_, this->duration_); +} + +void MotionBinarySensor::process_motion_data_(const MotionData &data) { + uint32_t now = App.get_loop_component_start_time(); + + switch (this->type_) { + case MOTION_BINARY_SENSOR_FACE_UP: + case MOTION_BINARY_SENSOR_FACE_DOWN: { + // Block while the device is moving: hold the last stable state instead of + // reacting to transient acceleration spikes from shaking or handling. + if (!is_stationary(data)) + break; + + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + float mag = std::sqrt(ax * ax + ay * ay + az * az); + // is_stationary_() guarantees mag is close to 1g, so this is just a safety net. + if (mag < 0.1f) + break; + + // threshold_ is the cosine of the maximum tilt: face_up / face_down are only + // reported when the device is within that tilt of horizontal. Beyond it, both + // sensors read false. Normalising by the magnitude makes the tilt limit + // independent of any residual acceleration. + float cos_tilt = az / mag; + if (this->type_ == MOTION_BINARY_SENSOR_FACE_UP) { + this->publish_state(cos_tilt > this->threshold_); + } else { + this->publish_state(cos_tilt < -this->threshold_); + } + break; + } + case MOTION_BINARY_SENSOR_FREE_FALL: { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) { + // Don't let a gap in valid data count towards the free-fall duration. + this->free_fall_candidate_ = false; + return; + } + + float mag = std::sqrt(ax * ax + ay * ay + az * az); + + if (mag < this->threshold_) { + if (!this->free_fall_candidate_) { + this->free_fall_candidate_ = true; + this->free_fall_start_time_ = now; + } else if (now - this->free_fall_start_time_ >= this->duration_) { + this->publish_state(true); + } + } else { + this->free_fall_candidate_ = false; + this->publish_state(false); + } + break; + } + case MOTION_BINARY_SENSOR_MOVING: { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + float gx = data.angular_rate[X_AXIS]; + float gy = data.angular_rate[Y_AXIS]; + float gz = data.angular_rate[Z_AXIS]; + + bool moving = false; + + // Check acceleration delta. Require all three axes to be valid so a NaN on any + // axis can't poison last_accel_ and silently stop motion detection. + bool accel_valid = !std::isnan(ax) && !std::isnan(ay) && !std::isnan(az); + if (accel_valid) { + if (!std::isnan(this->last_accel_[0])) { + float dx = ax - this->last_accel_[0]; + float dy = ay - this->last_accel_[1]; + float dz = az - this->last_accel_[2]; + float accel_diff = std::sqrt(dx * dx + dy * dy + dz * dz); + if (accel_diff > this->threshold_) { + moving = true; + } + } + this->last_accel_[0] = ax; + this->last_accel_[1] = ay; + this->last_accel_[2] = az; + } + + // Check angular rate delta. Require all three axes to be valid for the same reason. + bool gyro_valid = !std::isnan(gx) && !std::isnan(gy) && !std::isnan(gz); + if (gyro_valid) { + if (!std::isnan(this->last_gyro_[0])) { + float dgx = gx - this->last_gyro_[0]; + float dgy = gy - this->last_gyro_[1]; + float dgz = gz - this->last_gyro_[2]; + float gyro_diff = std::sqrt(dgx * dgx + dgy * dgy + dgz * dgz); + if (gyro_diff > this->threshold_ * GYRO_THRESHOLD_SCALE) { + moving = true; + } + } + this->last_gyro_[0] = gx; + this->last_gyro_[1] = gy; + this->last_gyro_[2] = gz; + } + + // With no usable data this sample, don't assert "not moving" -- just wait for + // the next one. + if (!accel_valid && !gyro_valid) + break; + + if (moving) { + this->publish_state(true); + this->last_event_time_ = now; + } else { + if (this->state && (now - this->last_event_time_ >= this->duration_)) { + this->publish_state(false); + } + } + break; + } + } +} + +} // namespace esphome::motion diff --git a/esphome/components/motion/binary_sensor/motion_binary_sensor.h b/esphome/components/motion/binary_sensor/motion_binary_sensor.h new file mode 100644 index 00000000000..6d556598d45 --- /dev/null +++ b/esphome/components/motion/binary_sensor/motion_binary_sensor.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "../motion_component.h" + +namespace esphome::motion { + +enum MotionBinarySensorType : uint8_t { + MOTION_BINARY_SENSOR_FACE_UP = 0, + MOTION_BINARY_SENSOR_FACE_DOWN, + MOTION_BINARY_SENSOR_FREE_FALL, + MOTION_BINARY_SENSOR_MOVING, +}; + +class MotionBinarySensor : public Component, public binary_sensor::BinarySensor { + public: + explicit MotionBinarySensor(MotionComponent *parent, MotionBinarySensorType type); + + void setup() override; + void dump_config() override; + + void set_threshold(float threshold) { this->threshold_ = threshold; } + void set_duration(uint32_t duration) { this->duration_ = duration; } + + protected: + void process_motion_data_(const MotionData &data); + + /// True when the device is at rest: total acceleration is close to 1g and (if a + /// gyroscope is present) the angular rate is low. While not stationary the + /// face_up / face_down orientation is unreliable, so their updates are suspended. + static bool is_stationary(const MotionData &data); + + MotionComponent *parent_; + float threshold_{0.0f}; + uint32_t duration_{0}; + + // Tracking states + uint32_t last_event_time_{0}; + uint32_t free_fall_start_time_{0}; + + // For derivative/variance tracking + float last_accel_[3]{NAN, NAN, NAN}; + float last_gyro_[3]{NAN, NAN, NAN}; + + MotionBinarySensorType type_; + bool free_fall_candidate_{false}; +}; + +} // namespace esphome::motion diff --git a/esphome/components/motion/event/__init__.py b/esphome/components/motion/event/__init__.py new file mode 100644 index 00000000000..9c72535f131 --- /dev/null +++ b/esphome/components/motion/event/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import event +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THRESHOLD +from esphome.types import ConfigType + +from .. import ( + CONF_MOTION_ID, + MotionComponent, + check_has_accelerometer, + check_update_interval, + motion_ns, +) + +DEPENDENCIES = ["motion"] + +MotionEvent = motion_ns.class_("MotionEvent", event.Event, cg.Component) + +EVENT_TYPES = ["shake"] + +CONF_COOLDOWN = "cooldown" + +CONFIG_SCHEMA = ( + event.event_schema(MotionEvent) + .extend( + { + cv.GenerateID(CONF_MOTION_ID): cv.use_id(MotionComponent), + cv.Optional(CONF_THRESHOLD, default=0.5): cv.float_range( + min=0.0, min_included=False + ), + cv.Optional( + CONF_COOLDOWN, default="500ms" + ): cv.positive_time_period_milliseconds, + } + ) + .extend(cv.COMPONENT_SCHEMA) +) + + +def _final_validate(config: dict) -> None: + check_update_interval(config[CONF_MOTION_ID], "shake") + check_has_accelerometer(config[CONF_MOTION_ID], "shake") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_MOTION_ID]) + var = cg.new_Pvariable(config[CONF_ID], parent) + await event.register_event(var, config, event_types=EVENT_TYPES) + await cg.register_component(var, config) + + cg.add(var.set_threshold(config[CONF_THRESHOLD])) + cg.add(var.set_cooldown(config[CONF_COOLDOWN])) diff --git a/esphome/components/motion/event/motion_event.cpp b/esphome/components/motion/event/motion_event.cpp new file mode 100644 index 00000000000..5568c953fda --- /dev/null +++ b/esphome/components/motion/event/motion_event.cpp @@ -0,0 +1,57 @@ +#include "motion_event.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/core/application.h" + +namespace esphome::motion { + +static const char *const TAG = "motion.event"; + +MotionEvent::MotionEvent(MotionComponent *parent) : parent_(parent) {} + +void MotionEvent::setup() { + this->parent_->add_listener([this](MotionData const &data) { this->process_motion_data_(data); }); +} + +void MotionEvent::dump_config() { + LOG_EVENT("", "Motion Event", this); + ESP_LOGCONFIG(TAG, + " Threshold: %.3f\n" + " Cooldown: %" PRIu32 " ms", + this->threshold_, this->cooldown_); +} + +void MotionEvent::process_motion_data_(const MotionData &data) { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) { + // Reset the baseline so the next valid sample doesn't jerk-compare across the gap. + this->last_accel_[0] = NAN; + this->last_accel_[1] = NAN; + this->last_accel_[2] = NAN; + return; + } + + uint32_t now = App.get_loop_component_start_time(); + + if (!std::isnan(this->last_accel_[0])) { + float dx = ax - this->last_accel_[0]; + float dy = ay - this->last_accel_[1]; + float dz = az - this->last_accel_[2]; + float jerk_mag = std::sqrt(dx * dx + dy * dy + dz * dz); + + if (jerk_mag > this->threshold_) { + if (now - this->last_trigger_time_ >= this->cooldown_) { + this->trigger("shake"); + this->last_trigger_time_ = now; + } + } + } + + this->last_accel_[0] = ax; + this->last_accel_[1] = ay; + this->last_accel_[2] = az; +} + +} // namespace esphome::motion diff --git a/esphome/components/motion/event/motion_event.h b/esphome/components/motion/event/motion_event.h new file mode 100644 index 00000000000..e8cd9f53520 --- /dev/null +++ b/esphome/components/motion/event/motion_event.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/event/event.h" +#include "../motion_component.h" + +namespace esphome::motion { + +class MotionEvent : public Component, public event::Event { + public: + explicit MotionEvent(MotionComponent *parent); + + void setup() override; + void dump_config() override; + + void set_threshold(float threshold) { this->threshold_ = threshold; } + void set_cooldown(uint32_t cooldown) { this->cooldown_ = cooldown; } + + protected: + void process_motion_data_(const MotionData &data); + + MotionComponent *parent_; + float threshold_{0.5f}; + uint32_t cooldown_{500}; + + // State tracking for shake detection + uint32_t last_trigger_time_{0}; + float last_accel_[3]{NAN, NAN, NAN}; +}; + +} // namespace esphome::motion 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..2ca920dbe0e 100644 --- a/esphome/components/motion/motion_component.h +++ b/esphome/components/motion/motion_component.h @@ -23,6 +23,33 @@ static constexpr uint8_t X_AXIS = 0; static constexpr uint8_t Y_AXIS = 1; static constexpr uint8_t Z_AXIS = 2; +/// Compute the device's in-plane orientation from the gravity vector. +/// +/// Returns NAN when the device is flat (lying face up or face down), i.e. when the +/// horizontal component of gravity, normalised by the total acceleration, is below +/// `flat_threshold` (the sine of the minimum tilt angle). Otherwise returns the +/// rotation snapped to the nearest of 0, 90, 180 or 270 degrees, derived from the +/// direction of the horizontal gravity component. +inline float orientation_degrees(const MotionData &data, float flat_threshold) { + float ax = data.acceleration[X_AXIS]; + float ay = data.acceleration[Y_AXIS]; + float az = data.acceleration[Z_AXIS]; + if (std::isnan(ax) || std::isnan(ay) || std::isnan(az)) + return NAN; + float mag = std::sqrt(ax * ax + ay * ay + az * az); + if (mag < 0.1f) + return NAN; + // Horizontal component of gravity; near zero when the device lies flat. + float h = std::sqrt(ax * ax + ay * ay); + if (h / mag < flat_threshold) + return NAN; + // Direction of the horizontal component, snapped to the nearest 90°. + float angle = std::atan2(ay, ax) * (180.0f / std::numbers::pi_v); + int quadrant = static_cast(std::lround(angle / 90.0f)); + quadrant = ((quadrant % 4) + 4) % 4; // normalise to 0..3 + return quadrant * 90.0f; +} + // Persisted calibration. `base_hash` ties the stored matrix to the build-time // (axis_map / transform_matrix) base; if the base changes the saved calibration // is ignored. Stored under a stable, ID-derived key so it overwrites in place. @@ -52,8 +79,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)); } @@ -77,7 +105,7 @@ class MotionComponent : public PollingComponent { output[2] = input[X_AXIS] * this->matrix_[6] + input[Y_AXIS] * this->matrix_[7] + input[Z_AXIS] * this->matrix_[8]; } - LazyCallbackManager motion_data_callback_{}; + LazyCallbackManager motion_data_callback_{}; uint32_t pref_key_{0}; uint32_t base_hash_{0}; // hash of base_matrix_, captured in setup() ESPPreferenceObject pref_{}; @@ -135,20 +163,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/motion/sensor.py b/esphome/components/motion/sensor.py index ad3163a01a5..db8a8692364 100644 --- a/esphome/components/motion/sensor.py +++ b/esphome/components/motion/sensor.py @@ -1,4 +1,6 @@ # YAML config keys +import math + import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -6,6 +8,7 @@ from esphome.const import ( CONF_TYPE, ICON_ACCELERATION, ICON_ROTATE_RIGHT, + ICON_SCREEN_ROTATION, STATE_CLASS_MEASUREMENT, UNIT_DEGREE_PER_SECOND, UNIT_DEGREES, @@ -13,7 +16,7 @@ from esphome.const import ( ) from esphome.cpp_generator import MockObj from esphome.cpp_types import std_ns -import esphome.final_validate as fv +from esphome.types import ConfigType from . import ( AXES, @@ -21,6 +24,7 @@ from . import ( KEY_ACCELEROMETER, KEY_GYROSCOPE, SENSOR_SCHEMA, + get_motion_config, motion_ns, ) @@ -28,10 +32,17 @@ MotionData = motion_ns.class_("MotionData") CONF_PITCH = "pitch" CONF_ROLL = "roll" +CONF_ORIENTATION = "orientation" +CONF_FLAT_THRESHOLD = "flat_threshold" ICON_SEESAW = "mdi:seesaw" +# Minimum tilt angle (degrees) before the device is considered tilted enough to +# report an orientation. Below this the device is treated as flat (sensor reports +# NAN). Configured in degrees; converted to the sine of the angle for the C++ side. +DEFAULT_FLAT_THRESHOLD = 30.0 -def _accel_sensor_schema(): + +def _accel_sensor_schema() -> cv.Schema: return sensor.sensor_schema( unit_of_measurement=UNIT_G, icon=ICON_ACCELERATION, @@ -40,7 +51,7 @@ def _accel_sensor_schema(): ).extend(SENSOR_SCHEMA) -def _gyro_sensor_schema(): +def _gyro_sensor_schema() -> cv.Schema: return sensor.sensor_schema( unit_of_measurement=UNIT_DEGREE_PER_SECOND, icon=ICON_ROTATE_RIGHT, @@ -49,7 +60,7 @@ def _gyro_sensor_schema(): ).extend(SENSOR_SCHEMA) -def _level_sensor_schema(): +def _level_sensor_schema() -> cv.Schema: return sensor.sensor_schema( unit_of_measurement=UNIT_DEGREES, icon=ICON_SEESAW, @@ -58,6 +69,28 @@ def _level_sensor_schema(): ).extend(SENSOR_SCHEMA) +def _orientation_sensor_schema() -> cv.Schema: + # Reports a discrete rotation (0/90/180/270) or NAN when flat, so it is not a + # continuous measurement (no state_class). + return ( + sensor.sensor_schema( + unit_of_measurement=UNIT_DEGREES, + icon=ICON_SCREEN_ROTATION, + accuracy_decimals=0, + ) + .extend(SENSOR_SCHEMA) + .extend( + { + # 90 is excluded: sin(90) == 1.0 would make the C++ comparison always + # true, so orientation would report NAN (flat) on every reading. + cv.Optional( + CONF_FLAT_THRESHOLD, default=DEFAULT_FLAT_THRESHOLD + ): cv.float_range(min=0.0, max=90.0, max_included=False), + } + ) + ) + + _ACCELERATIONS = ["acceleration_" + a for a in AXES] _GYROSCOPES = ["gyroscope_" + g for g in AXES] _ANGULAR_RATES = ["angular_rate_" + r for r in AXES] @@ -68,20 +101,20 @@ CONFIG_SCHEMA = cv.typed_schema( **{x: _gyro_sensor_schema() for x in _GYROSCOPES}, **{x: _gyro_sensor_schema() for x in _ANGULAR_RATES}, **{x: _level_sensor_schema() for x in (CONF_PITCH, CONF_ROLL)}, + CONF_ORIENTATION: _orientation_sensor_schema(), } ) def _final_validate(config: dict) -> None: - full_config = fv.full_config.get() - motion_path = full_config.get_path_for_id(config[CONF_MOTION_ID])[:-1] - motion_config = full_config.get_config_for_path(motion_path) + motion_config = get_motion_config(config[CONF_MOTION_ID]) has_accel = motion_config.get(KEY_ACCELEROMETER, False) has_gyro = motion_config.get(KEY_GYROSCOPE, False) sensor_type = config[CONF_TYPE] if ( - sensor_type in _ACCELERATIONS or sensor_type in (CONF_ROLL, CONF_PITCH) + sensor_type in _ACCELERATIONS + or sensor_type in (CONF_ROLL, CONF_PITCH, CONF_ORIENTATION) ) and not has_accel: raise cv.Invalid( "The motion device does not measure acceleration", path=[CONF_TYPE] @@ -95,11 +128,15 @@ def _final_validate(config: dict) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj: +def build_sensor_expr(sensor_type: str, data: MockObj, config: dict) -> MockObj: """Build the C++ expression for a motion sensor type.""" # Note that is included via this component's header file. pif = std_ns.namespace("numbers").pi_v.template(cg.float_) + if sensor_type == CONF_ORIENTATION: + # The C++ helper compares against the sine of the tilt angle. + threshold = round(math.sin(math.radians(config[CONF_FLAT_THRESHOLD])), 6) + return motion_ns.orientation_degrees(data, threshold) if sensor_type == CONF_ROLL: ay = data.acceleration[1] az = data.acceleration[2] @@ -115,14 +152,14 @@ def build_sensor_expr(sensor_type: str, data: MockObj) -> MockObj: return getattr(data, str(sensor_type[:-2]))[sensor_offset] -async def to_code(config): +async def to_code(config: ConfigType) -> None: sensor_type = config[CONF_TYPE] var = await sensor.new_sensor(config) parent = await cg.get_variable(config[CONF_MOTION_ID]) data = MockObj("data") - expr = build_sensor_expr(sensor_type, data) + expr = build_sensor_expr(sensor_type, data, config) value_lambda = await cg.process_lambda( var.publish_state(expr), - [(MotionData.operator("ref"), str(data))], + [(MotionData.operator("const").operator("ref"), str(data))], ) cg.add(parent.add_listener(value_lambda)) diff --git a/esphome/components/mpl3115a2/sensor.py b/esphome/components/mpl3115a2/sensor.py index 4fbc353644a..8235e2d6183 100644 --- a/esphome/components/mpl3115a2/sensor.py +++ b/esphome/components/mpl3115a2/sensor.py @@ -64,13 +64,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PRESSURE in config: - sens = await sensor.new_sensor(config[CONF_PRESSURE]) - cg.add(var.set_pressure(sens)) - elif CONF_ALTITUDE in config: - sens = await sensor.new_sensor(config[CONF_ALTITUDE]) - cg.add(var.set_altitude(sens)) - - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PRESSURE, var.set_pressure) + await sensors(CONF_ALTITUDE, var.set_altitude) + await sensors(CONF_TEMPERATURE, var.set_temperature) diff --git a/esphome/components/mpu6050/sensor.py b/esphome/components/mpu6050/sensor.py index a8370ced7fe..bd3de2d32cd 100644 --- a/esphome/components/mpu6050/sensor.py +++ b/esphome/components/mpu6050/sensor.py @@ -70,16 +70,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - for d in ["x", "y", "z"]: - accel_key = f"accel_{d}" - if accel_key in config: - sens = await sensor.new_sensor(config[accel_key]) - cg.add(getattr(var, f"set_accel_{d}_sensor")(sens)) - accel_key = f"gyro_{d}" - if accel_key in config: - sens = await sensor.new_sensor(config[accel_key]) - cg.add(getattr(var, f"set_gyro_{d}_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_ACCEL_X, var.set_accel_x_sensor) + await sensors(CONF_GYRO_X, var.set_gyro_x_sensor) + await sensors(CONF_ACCEL_Y, var.set_accel_y_sensor) + await sensors(CONF_GYRO_Y, var.set_gyro_y_sensor) + await sensors(CONF_ACCEL_Z, var.set_accel_z_sensor) + await sensors(CONF_GYRO_Z, var.set_gyro_z_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/mpu6886/sensor.py b/esphome/components/mpu6886/sensor.py index 5bdb8361287..878540d5160 100644 --- a/esphome/components/mpu6886/sensor.py +++ b/esphome/components/mpu6886/sensor.py @@ -71,16 +71,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - for d in ["x", "y", "z"]: - accel_key = f"accel_{d}" - if accel_key in config: - sens = await sensor.new_sensor(config[accel_key]) - cg.add(getattr(var, f"set_accel_{d}_sensor")(sens)) - accel_key = f"gyro_{d}" - if accel_key in config: - sens = await sensor.new_sensor(config[accel_key]) - cg.add(getattr(var, f"set_gyro_{d}_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_ACCEL_X, var.set_accel_x_sensor) + await sensors(CONF_GYRO_X, var.set_gyro_x_sensor) + await sensors(CONF_ACCEL_Y, var.set_accel_y_sensor) + await sensors(CONF_GYRO_Y, var.set_gyro_y_sensor) + await sensors(CONF_ACCEL_Z, var.set_accel_z_sensor) + await sensors(CONF_GYRO_Z, var.set_gyro_z_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 3050ceb1a4b..65a7cf409b8 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -1,12 +1,11 @@ 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 ( add_idf_component, - add_idf_sdkconfig_option, idf_version, include_builtin_idf_component, + request_tls, ) from esphome.config_helpers import ( filter_source_files_from_defines, @@ -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 @@ -364,8 +359,8 @@ async def to_code(config): add_idf_component(name="espressif/mqtt", ref="1.0.0") else: include_builtin_idf_component("mqtt") - # mqtt_client.h drags in esp_tls types; esp-tls is excluded by default - include_builtin_idf_component("esp-tls") + # esp-mqtt links transport_ssl.c (esp_tls) even for plain MQTT + request_tls() cg.add_define("USE_MQTT") cg.add_global(mqtt_ns.using) @@ -457,10 +452,6 @@ async def to_code(config): cg.add(var.set_cl_certificate(config[CONF_CLIENT_CERTIFICATE])) cg.add(var.set_cl_key(config[CONF_CLIENT_CERTIFICATE_KEY])) - # prevent error -0x428e - # See https://github.com/espressif/esp-idf/issues/139 - add_idf_sdkconfig_option("CONFIG_MBEDTLS_HARDWARE_MPI", False) - if CONF_IDF_SEND_ASYNC in config and config[CONF_IDF_SEND_ASYNC]: cg.add_define("USE_MQTT_IDF_ENQUEUE") # end esp-idf @@ -505,22 +496,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 +594,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_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/ms5611/sensor.py b/esphome/components/ms5611/sensor.py index e42e0824dde..bf6d4528e68 100644 --- a/esphome/components/ms5611/sensor.py +++ b/esphome/components/ms5611/sensor.py @@ -50,10 +50,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature_sensor(sens)) - - if pressure := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure) - cg.add(var.set_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) diff --git a/esphome/components/ms8607/sensor.py b/esphome/components/ms8607/sensor.py index e5d90432d8a..13377394beb 100644 --- a/esphome/components/ms8607/sensor.py +++ b/esphome/components/ms8607/sensor.py @@ -68,17 +68,12 @@ 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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) + if await sensors(CONF_HUMIDITY, var.set_humidity_sensor): + humidity_config = config[CONF_HUMIDITY] humidity_device = cg.new_Pvariable(humidity_config[CONF_HUMIDITY_I2C_ID]) await i2c.register_i2c_device(humidity_device, humidity_config) cg.add(var.set_humidity_device(humidity_device)) 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/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 a5894bdaf7a..18f0a73928d 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -56,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. " @@ -160,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), @@ -170,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 = ( @@ -284,10 +271,7 @@ async def to_code(config): ) if CORE.is_esp32: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) - # and esp-tls, whose sdkconfig options below need the component present - esp32.include_builtin_idf_component("esp_http_client") - esp32.include_builtin_idf_component("esp-tls") + esp32.request_http_client() esp32.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) esp32.add_idf_sdkconfig_option( "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 6c9c8760f8a..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. 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/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 6067fde1642..d9880fe6f9c 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,12 +5,17 @@ from typing import Any import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENCRYPTION, CONF_KEY -from esphome.core import ID +from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +# 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.30" +LIBSODIUM_VERSION = "1.10021.11" + noise_ns = cg.esphome_ns.namespace("noise") CONFIG_SCHEMA = cv.Schema({}) @@ -68,12 +73,12 @@ def static_encryption_key(conf: ConfigType) -> str | None: return (conf.get(CONF_ENCRYPTION) or {}).get(CONF_KEY) or None -def new_psk_progmem(parent_id: ID, key: str) -> MockObj: +def new_psk_progmem(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.""" + return cg.shared_progmem_array( + "noise_psk", cg.uint8, list(decode_encryption_key(key)) ) @@ -86,14 +91,39 @@ def encryption_schema(config: ConfigType | None) -> ConfigType: return ENCRYPTION_SCHEMA(config) +def _use_managed_components() -> bool: + """Whether noise-c and libsodium come from the ESP-IDF component registry. + + Both build as ESP-IDF components, so on ESP32 they skip the PlatformIO library + converter unless arduino-esp32 bundles its own libsodium. Not toolchain + dependent: the PlatformIO toolchain reads the project manifest too, and every + consumer of libsodium must make the same choice or a second copy appears. + """ + if not CORE.is_esp32: + return False + + from esphome.components.esp32 import arduino_bundles_libsodium + + return not arduino_bundles_libsodium() + + +def enable_spare_ephemeral() -> None: + """Compile the spare ephemeral key slot; the component that refills it calls this.""" + cg.add_define("USE_NOISE_SPARE_EPHEMERAL") + + async def to_code(config: ConfigType) -> None: cg.add_define("USE_NOISE") - cg.add_library("esphome/noise-c", "0.1.30") - # noise-c depends on libsodium, but declaring it here too lets the - # library manager see the full set up front instead of discovering - # libsodium only after noise-c has downloaded, so the two can download - # in parallel. The version must match noise-c's library.json. - cg.add_library("esphome/libsodium", "1.10021.11") + # libsodium is declared next to noise-c so the library manager sees both up front + # and nothing else pulls a second copy; the version must match noise-c's own pin + if _use_managed_components(): + from esphome.components.esp32 import add_idf_component + + add_idf_component(name="esphome/noise-c", ref=NOISE_C_VERSION) + add_idf_component(name="esphome/libsodium", ref=LIBSODIUM_VERSION) + else: + cg.add_library("esphome/noise-c", NOISE_C_VERSION) + cg.add_library("esphome/libsodium", LIBSODIUM_VERSION) # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1") diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp index 48067061673..118bfb234fd 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -1,16 +1,15 @@ #include "noise.h" #ifdef USE_NOISE #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include #include #include - -#ifdef USE_ESP8266 -#include -#endif +#include namespace esphome::noise { @@ -24,6 +23,38 @@ void NoiseContext::load_psk(psk_t &out) const { progmem_memcpy(out.data(), this->psk_, out.size()); } +#ifdef USE_NOISE_SPARE_EPHEMERAL +static constexpr size_t PRIVATE_KEY_SIZE = SPARE_EPHEMERAL_KEY_SIZE; +static constexpr size_t PUBLIC_KEY_SIZE = SPARE_EPHEMERAL_KEY_SIZE; +uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +void prepare_spare_ephemeral() { + uint8_t *private_key = spare_ephemeral; + uint8_t *public_key = spare_ephemeral + PRIVATE_KEY_SIZE; + // Same steps as noise-c's keygen; the clamp sets the ready bit + if (!random_bytes(private_key, PRIVATE_KEY_SIZE)) { + sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral)); + return; + } + private_key[0] &= 0xF8; + private_key[PRIVATE_KEY_SIZE - 1] = (private_key[PRIVATE_KEY_SIZE - 1] & 0x7F) | 0x40; + if (crypto_scalarmult_curve25519_base(public_key, private_key) != 0) { + sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral)); + } +} + +int consume_spare_ephemeral(NoiseHandshakeState *state) { + if (!has_spare_ephemeral()) { + return 0; + } + // noise-c keeps its own copy, so the slot is wiped either way + int err = noise_handshakestate_set_local_ephemeral(state, spare_ephemeral, PRIVATE_KEY_SIZE, + spare_ephemeral + PRIVATE_KEY_SIZE, PUBLIC_KEY_SIZE); + sodium_memzero(spare_ephemeral, sizeof(spare_ephemeral)); + return err; +} +#endif // USE_NOISE_SPARE_EPHEMERAL + const LogString *noise_err_to_logstr(int err) { if (err == NOISE_ERROR_NO_MEMORY) return LOG_STR("NO_MEMORY"); @@ -74,22 +105,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.h b/esphome/components/noise/noise.h index 1033d5423ce..271a829944a 100644 --- a/esphome/components/noise/noise.h +++ b/esphome/components/noise/noise.h @@ -6,6 +6,9 @@ #include #include "esphome/core/log.h" +// Forward decl from +using NoiseHandshakeState = struct NoiseHandshakeState_s; + namespace esphome::noise { using psk_t = std::array; @@ -38,6 +41,21 @@ class NoiseContext { /// Convert a noise error code to a readable error const LogString *noise_err_to_logstr(int err); +#ifdef USE_NOISE_SPARE_EPHEMERAL +// One responder ephemeral key pair made ahead of time (about 60 ms on ESP8266), refilled by the api server +// while idle and consumed by the next noise handshake of any transport; empty means the handshake generates +// its own. Private key then public key; the private key stays in RAM until consumed. +static constexpr size_t SPARE_EPHEMERAL_KEY_SIZE = 32; +static constexpr size_t SPARE_EPHEMERAL_SIZE = 2 * SPARE_EPHEMERAL_KEY_SIZE; +extern uint8_t spare_ephemeral[SPARE_EPHEMERAL_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +// The clamp always sets bit 254, so it doubles as the ready flag +inline bool has_spare_ephemeral() { return (spare_ephemeral[SPARE_EPHEMERAL_KEY_SIZE - 1] & 0x40) != 0; } +/// Fill the slot; blocks for the base point multiply +void prepare_spare_ephemeral(); +/// Move the key pair into a handshake that has not started and wipe the slot; 0 unless noise-c rejects it +int consume_spare_ephemeral(NoiseHandshakeState *state); +#endif + // Shared wire format for the noise transports (api and ota): every frame is // FRAME_INDICATOR, a 16-bit big-endian payload length, then the payload. // Handshake payloads start with a status byte; transport payloads end with diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index cc7fa603c4e..bd1afa198d3 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -57,6 +57,13 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog HANDSHAKE_STEP_LOG("noise_handshakestate_set_prologue", err); return this->fail_init_(err); } +#ifdef USE_NOISE_SPARE_EPHEMERAL + err = consume_spare_ephemeral(this->handshake_); + // Not fatal: the handshake generates its own key instead + if (err != 0) { + HANDSHAKE_STEP_LOG("noise_handshakestate_set_local_ephemeral", err); + } +#endif err = noise_handshakestate_start(this->handshake_); if (err != 0) { HANDSHAKE_STEP_LOG("noise_handshakestate_start", err); @@ -95,6 +102,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 +111,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/noise/noise_handshake.h b/esphome/components/noise/noise_handshake.h index bf1aa8cb7fb..ef556a26b00 100644 --- a/esphome/components/noise/noise_handshake.h +++ b/esphome/components/noise/noise_handshake.h @@ -37,7 +37,8 @@ class NoiseResponderHandshake { NoiseResponderHandshake &operator=(const NoiseResponderHandshake &) = delete; /// Create and start the handshake with the context's PSK and the prologue. - /// A repeated call frees the previous handshake state and starts over. + /// A repeated call frees the previous handshake state and starts over. A + /// spare ephemeral key, when one is ready, is used instead of generating. [[nodiscard]] int init(const NoiseContext &ctx, const uint8_t *prologue, size_t prologue_len); /// ACTION_FAILED is the catch-all: returned before init(), after split() /// has released the state, and when noise-c reports a failed handshake. diff --git a/esphome/components/npi19/sensor.py b/esphome/components/npi19/sensor.py index 4c7db10ce98..14dcd72384d 100644 --- a/esphome/components/npi19/sensor.py +++ b/esphome/components/npi19/sensor.py @@ -44,10 +44,6 @@ 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)) - - if raw_pressure_config := config.get(CONF_RAW_PRESSURE): - sens = await sensor.new_sensor(raw_pressure_config) - cg.add(var.set_raw_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_RAW_PRESSURE, var.set_raw_pressure_sensor) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index aeeaba0c114..86d485777ca 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -8,6 +8,8 @@ import shutil import subprocess from esphome import pins +from esphome.build_helpers import pch +from esphome.build_helpers.ccache import resolve_ccache_path import esphome.codegen as cg from esphome.components.zephyr import ( add_extra_script, @@ -25,6 +27,7 @@ from esphome.components.zephyr.const import ( CONF_CDC_ACM, KEY_BOARD, KEY_BOOTLOADER, + KEY_SYSBUILD, KEY_ZEPHYR, CdcAcm, ) @@ -74,6 +77,8 @@ from .framework import ( get_build_env, get_build_paths, setup_platformio_python_env, + toolchain_tool, + wanted_west_projects, ) # force import gpio to register pin schema @@ -175,6 +180,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 +256,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 +302,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 +408,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 +422,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 +435,19 @@ 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 + if framework_ver >= cv.Version(2, 9, 2): + 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 +456,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) @@ -474,8 +499,8 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: types = [] UF2_PATH = "zephyr/zephyr.uf2" DFU_PATH = "firmware.zip" - HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1, only generated when OTA is disabled - HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2, always generated + HEX_PATH = "zephyr/zephyr.hex" # SDK 2.6.1 without OTA, SDK 3.4.0+ + HEX_MERGED_PATH = "zephyr/merged.hex" # SDK 2.9.2 to 3.3.x, always generated APP_IMAGE_PATH = "zephyr/app_update.bin" build_dir = Path(storage_json.firmware_bin_path).parent if (build_dir / UF2_PATH).is_file(): @@ -506,15 +531,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 @@ -562,7 +587,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: raise EsphomeError("Not implemented yet") check_and_install() paths = get_build_paths() - env = get_build_env() + env = get_build_env(None) # no compile, just nrfutil build_dir = CORE.relative_pioenvs_path(CORE.name) dfu_package = build_dir / "firmware.zip" if not dfu_package.is_file(): @@ -644,7 +669,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: if not CORE.using_toolchain_platformio: check_and_install() paths = get_build_paths() - env = get_build_env() + env = get_build_env(resolve_ccache_path()) # west flash may rebuild build_dir = CORE.relative_pioenvs_path(CORE.name) west_cmd = [ str(paths["python_executable"]), @@ -762,7 +787,75 @@ 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; before the + first build too, or its compiles hash the path laden .gch instead. + The app image dir follows the SDK version, like get_elf_path; + 2.9.2+ always wraps the build in sysbuild.""" + app_dir = build_dir + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 9, 2): + app_dir = build_dir / "zephyr" + checksum = pch.pch_checksum( + CORE.relative_src_path(), + pch.PCH_DEFAULT_HEADERS, + ( + str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]), + zephyr_data()[KEY_BOARD], + # Kconfig inputs that reach autoconf.h without a .conf line + ",".join(sorted(wanted_west_projects())), + str(zephyr_data().get(KEY_SYSBUILD)), + # 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 +898,9 @@ def _generate_cmake_lists() -> bool: ")", ] + if pch_on: + lines += _PCH_CMAKE_LINES + if link_flags: lines += [ "", @@ -824,6 +920,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 @@ -839,9 +955,13 @@ def run_compile(args, config: ConfigType) -> bool: check_and_install() paths = get_build_paths() - env = get_build_env() + # Depend mode in the shared ccache settings keeps the .gch sound + # across Kconfig flips. + ccache = resolve_ccache_path() + env = get_build_env(ccache) - 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 +978,18 @@ 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), - ] + # SDK 3.4.0+ no longer generates merged.hex; drop one left by an older SDK + # build so it is never packaged or offered for download. + for stale_hex in (build_dir / "merged.hex", build_dir / "zephyr" / "merged.hex"): + stale_hex.unlink(missing_ok=True) + + if pch_on: + pch.log_pch_in_use() + _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, @@ -905,13 +1025,18 @@ def run_compile(args, config: ConfigType) -> bool: west_out = zephyr_dir / "zephyr" _copy_if_exists(west_out / "zephyr.uf2", zephyr_dir / "zephyr.uf2") _copy_if_exists(west_out / "zephyr.signed.bin", zephyr_dir / "app_update.bin") + _copy_if_exists(west_out / "zephyr.hex", zephyr_dir / "zephyr.hex") _copy_if_exists(build_dir / "merged.hex", zephyr_dir / "merged.hex") - # For Adafruit bootloader builds, regenerate the UF2 from merged.hex, - # whose records carry the correct flash addresses. The build's own - # zephyr.uf2 uses the board's default offset, which is wrong in some cases. - merged_hex = zephyr_dir / "merged.hex" - if bootloader in _UF2_FAMILY_IDS and merged_hex.is_file(): + # For Adafruit bootloader builds, regenerate the UF2 from a hex file. + # merged.hex carries the correct flash addresses; SDK 3.4.0+ no longer + # generates it, so use zephyr.hex there. Chosen by version so a merged.hex + # left by an older SDK build is never picked. + if framework_ver >= cv.Version(3, 4, 0): + hex_file = zephyr_dir / "zephyr.hex" + else: + hex_file = zephyr_dir / "merged.hex" + if bootloader in _UF2_FAMILY_IDS and hex_file.is_file(): # Drop the build's own wrong-offset UF2 so it isn't shipped alongside. app_uf2 = west_out / "zephyr.uf2" if app_uf2.is_file(): @@ -928,12 +1053,12 @@ def run_compile(args, config: ConfigType) -> bool: "-c", "-o", str(zephyr_dir / "zephyr.uf2"), - str(merged_hex), + str(hex_file), ], env=env, stream_output=True, ): - raise EsphomeError("Failed to generate UF2 from merged hex") + raise EsphomeError(f"Failed to generate UF2 from {hex_file.name}") if bootloader in ( BOOTLOADER_ADAFRUIT, @@ -941,9 +1066,6 @@ def run_compile(args, config: ConfigType) -> bool: BOOTLOADER_ADAFRUIT_NRF52_SD140_V6, BOOTLOADER_ADAFRUIT_NRF52_SD140_V7, ): - # no fallback is needed for adafruit case. merged merged.hex is always generated. - # get_download_types needs fallback for mcuboot (non adafruit) - hex_file = zephyr_dir / "merged.hex" dfu_package = build_dir / "firmware.zip" genpkg_cmd = [ str(paths["python_executable"]), diff --git a/esphome/components/nrf52/clang_tidy.py b/esphome/components/nrf52/clang_tidy.py index 2dd4b7bd09f..d1f95979767 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,10 +272,12 @@ 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, - env=get_build_env(), + env=get_build_env(None), # configure only, nothing compiles stream_output=True, cwd=str(paths["framework_path"]), ): 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..00796562f42 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 @@ -6,7 +10,9 @@ import platform import shutil import sys +from esphome.build_helpers.ccache import ccache_env 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 @@ -23,6 +29,26 @@ _LOGGER = logging.getLogger(__name__) _REQUIREMENTS = Path(__file__).parent / "requirements.txt" TOOLCHAIN_VERSION = "0.17.4" +# Zephyr SDK used by nRF Connect SDK 3.4.0 and newer. +_TOOLCHAIN_VERSION_NCS_3_4_0 = "1.0.1" +_TOOLCHAIN_VERSIONS = (TOOLCHAIN_VERSION, _TOOLCHAIN_VERSION_NCS_3_4_0) + + +def _uses_sdk_ng_1_toolchain() -> bool: + """True when the framework needs Zephyr SDK 1.0+. + + SDK 1.0 moved the GNU toolchain under gnu/ and renamed its archive + to toolchain_gnu_*. + """ + return CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(3, 4, 0) + + +def _get_toolchain_version() -> str: + """Return the Zephyr SDK toolchain version for the current framework.""" + if _uses_sdk_ng_1_toolchain(): + return _TOOLCHAIN_VERSION_NCS_3_4_0 + return TOOLCHAIN_VERSION + # Packages the PlatformIO toolchain's Zephyr build script needs beyond west # (which comes from requirements.txt). Keep the pin in sync with @@ -35,6 +61,20 @@ SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str( "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/toolchain_{sysname}-{machine}_arm-zephyr-eabi.{extension}", ) ) +_SDK_NG_TOOLCHAIN_GNU_MIRRORS = str_to_lst_of_str( + os.environ.get( + "ESPHOME_SDK_NG_TOOLCHAIN_GNU_MIRRORS", + "https://github.com/zephyrproject-rtos/sdk-ng/releases/download/v{VERSION}/toolchain_gnu_{sysname}-{machine}_arm-zephyr-eabi.{extension}", + ) +) + + +def _get_sdk_ng_toolchain_mirrors() -> list[str]: + """Return toolchain mirror URLs for the current framework version.""" + if _uses_sdk_ng_1_toolchain(): + return _SDK_NG_TOOLCHAIN_GNU_MIRRORS + return SDK_NG_TOOLCHAIN_MIRRORS + # Minimal SDK provides cmake discovery files (Zephyr-sdkConfig.cmake) and # host tools (dtc etc.) required by the Zephyr cmake build system. @@ -80,6 +120,24 @@ def _get_toolchain_path(version: str) -> Path: return get_sdk_nrf_tools_path() / "toolchains" / version +def _get_arm_toolchain_path() -> Path: + """The arm-zephyr-eabi directory inside the pinned Zephyr SDK. + + The single owner of the SDK 0.x (arm-zephyr-eabi/) and SDK 1.0+ + (gnu/arm-zephyr-eabi/) layouts. + """ + toolchain_root = _get_toolchain_path(_get_toolchain_version()) + if _uses_sdk_ng_1_toolchain(): + return toolchain_root / "gnu" / "arm-zephyr-eabi" + return toolchain_root / "arm-zephyr-eabi" + + +def toolchain_tool(name: str) -> Path: + """Path to one of the pinned Zephyr SDK's tools (objdump, readelf, ...).""" + suffix = ".exe" if os.name == "nt" else "" + return _get_arm_toolchain_path() / "bin" / f"arm-zephyr-eabi-{name}{suffix}" + + _SITECUSTOMIZE = """\ import os, stat, shutil _orig = shutil.rmtree @@ -137,7 +195,14 @@ def get_build_paths() -> dict: } -def get_build_env() -> dict: +def get_build_env(ccache: str | None) -> dict: + """Build the west/sdk-nrf process environment. + + ``ccache`` is the resolved binary (resolve_ccache_path), or None when + ccache is disabled or the caller never compiles; it brings the shared + managed-ccache settings and the pch sloppiness, so every caller that + may compile gets the same cache. + """ version = _get_version_str() venv_bin_dir = get_python_env_executable_path( _get_python_env_path(version), "python" @@ -153,7 +218,26 @@ def get_build_env() -> dict: # "Zephyr-sdk_DIR" environment hint proved unreliable here: containerized # non-root builds failed to locate the SDK with it, while # ZEPHYR_SDK_INSTALL_DIR fixed the same invocation. - env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION)) + env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(_get_toolchain_version())) + if ccache is None: + # Zephyr wraps compiles with any ccache it finds; unmanaged it + # must not cache (a sysbuild image never sees USE_CCACHE=0). + env.setdefault("CCACHE_DISABLE", "1") + else: + env.update(ccache_env(ccache, SDK_NRF_TOOLS_CACHE)) + # Drop only the per build map entry (posix, CMake's spelling); + # its from side covers no compiled sources. A spaced path cannot + # survive ccache's space split list, so it stays hashed. + source_dir = CORE.relative_build_path("zephyr").as_posix() + if any(ch.isspace() for ch in source_dir): + _LOGGER.debug( + "Whitespace in %s; the per build map stays hashed", source_dir + ) + else: + device_map = f"-fmacro-prefix-map={source_dir}=CMAKE_SOURCE_DIR" + env["CCACHE_IGNOREOPTIONS"] = ( + f"{env.get('CCACHE_IGNOREOPTIONS', '')} {device_map}".strip() + ) return env @@ -230,25 +314,303 @@ def setup_platformio_python_env() -> None: _prepend_env_path("PATH", str(env_python_path.parent)) +def _patch_framework_file(path: Path, old: str, new: str) -> bool: + """Replace ``old`` with ``new`` in a framework script, atomically and + keeping the file mode (helpers.write_file would flatten it to 0o644). + Returns False when nothing matched.""" + import tempfile + + content = path.read_text(encoding="utf-8") + patched = content.replace(old, new) + if patched == content: + return False + # Unique sibling tmp: the install lock is best effort, so two builds + # may patch at once and a shared tmp name could rename a half + # written file into place. + fd, tmp_name = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(patched) + shutil.copymode(path, tmp) + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + return True + + def _patch_uf2conv_escape_sequences(framework_path: Path) -> None: # SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that # Python 3.12+ flags with SyntaxWarning (a future version will reject it). uf2conv = framework_path / "zephyr" / "scripts" / "build" / "uf2conv.py" - if not uf2conv.exists(): + if uf2conv.exists(): + _patch_framework_file( + uf2conv, "re.split('\\s+', line)", "re.split('\\\\s+', line)" + ) + + +def _patch_gen_defines_dts_path(framework_path: Path) -> None: + # The absolute zephyr.dts.pre path in the header's top comment is + # its only per device byte and blocks ccache sharing; emit the + # basename. Upstream candidate. + gen_defines = framework_path / "zephyr" / "scripts" / "dts" / "gen_defines.py" + if not gen_defines.exists(): return - content = uf2conv.read_text(encoding="utf-8") - patched = content.replace("re.split('\\s+', line)", "re.split('\\\\s+', line)") - if patched == content: + if _patch_framework_file( + gen_defines, " {edt.dts_path}", " {os.path.basename(edt.dts_path)}" + ): return - # Write atomically so a concurrent build never sees a truncated file - tmp = uf2conv.with_suffix(".py.tmp") - tmp.write_text(patched, encoding="utf-8") - shutil.copymode(uf2conv, tmp) - tmp.replace(uf2conv) + if "{os.path.basename(edt.dts_path)}" not in gen_defines.read_text( + encoding="utf-8" + ): + # Upstream reformatted the comment; sharing silently degrading + # would be invisible, so say it out loud. + _LOGGER.warning( + "gen_defines.py no longer matches; the devicetree header " + "stays per device and ccache sharing between devices degrades" + ) + + +# 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 +641,17 @@ 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) + # Every run: existing installs need it too, and it is a no-op once applied + _patch_gen_defines_dts_path(framework_path) zephyr_sentinel = python_env_path / ".zephyr_reqs_ready" if ( @@ -331,29 +672,41 @@ def check_and_install() -> None: raise EsphomeError(f"Install Zephyr requirements for {version} failure") zephyr_sentinel.touch() - toolchains_dir = _get_toolchain_path(TOOLCHAIN_VERSION) + # Shared by every SDK version that uses the same toolchain; locked only + # while missing + toolchain_version = _get_toolchain_version() + if not (_get_toolchain_path(toolchain_version) / ".ready").exists(): + with _install_lock(f"toolchain-{toolchain_version}"): + _install_toolchain() + + +def _install_toolchain() -> None: + toolchain_version = _get_toolchain_version() + toolchains_dir = _get_toolchain_path(toolchain_version) sentinel = toolchains_dir / ".ready" if not sentinel.exists(): - rmdir(toolchains_dir, msg=f"Clean up {TOOLCHAIN_VERSION} toolchain environment") + rmdir(toolchains_dir, msg=f"Clean up {toolchain_version} toolchain environment") sysname, machine, extension = _get_toolchain_platform_info() substitutions = { - "VERSION": TOOLCHAIN_VERSION, + "VERSION": toolchain_version, "sysname": sysname, "machine": machine, "extension": extension, } # Downloaded next to the destination (not a temp file) so an # interrupted download's .part file resumes on the next run. + # SDK 1.0+ Zephyr-sdkConfig.cmake looks for the toolchain in + # gnu/arm-zephyr-eabi/; extraction strips the archive's single root. for mirrors, extract_dir, what, slug in ( (SDK_NG_MINIMAL_MIRRORS, toolchains_dir, "Zephyr SDK minimal", "minimal"), ( - SDK_NG_TOOLCHAIN_MIRRORS, - toolchains_dir / "arm-zephyr-eabi", + _get_sdk_ng_toolchain_mirrors(), + _get_arm_toolchain_path(), "toolchain", "toolchain", ), ): - _LOGGER.info("Downloading %s %s ...", TOOLCHAIN_VERSION, what) + _LOGGER.info("Downloading %s %s ...", toolchain_version, what) download_and_extract( mirrors, substitutions, @@ -361,10 +714,17 @@ def check_and_install() -> None: extract_dir, progress_header="Extracting", ) - # Best-effort prune of resume leftovers, including a previous - # TOOLCHAIN_VERSION's orphans; the SDK archives are hundreds of MB. - # A locked file must not discard the just-completed install. + # Best-effort prune of resume leftovers, including orphans of retired + # toolchain versions; the SDK archives are hundreds of MB. The other + # toolchain still in use may be downloading under its own lock, so its + # leftovers are kept. A locked file must not discard the just-completed + # install. + other_versions = tuple( + f"{v}." for v in _TOOLCHAIN_VERSIONS if v != toolchain_version + ) for leftover in toolchains_dir.parent.glob("*.archive.part*"): + if leftover.name.startswith(other_versions): + continue try: leftover.unlink() except OSError as err: 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 fc0893323be..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 @@ -329,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), @@ -366,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( @@ -453,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/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 a71151f3ffb..80b28a26285 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,12 +13,14 @@ from esphome.components.esp32 import ( get_esp32_variant, include_builtin_idf_component, only_on_variant, + require_mbedtls_ecp, 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 @@ -30,17 +32,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 @@ -111,9 +113,9 @@ 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. + # Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked; + # setting one under sdkconfig_options keeps TLS in the build automatically. + # The crypto platform uses AES-CCM and deterministic ECDSA directly. require_mbedtls_tls_server() require_mbedtls_tls_extras( ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") @@ -292,6 +294,8 @@ async def to_code(config: ConfigType) -> None: # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") + # OPENTHREAD_CONFIG_ECDSA_ENABLE: the SRP client host key uses mbedtls_ecdsa_* + require_mbedtls_ecp() cg.add_define("USE_OPENTHREAD") if config.get(CONF_FORCE_DATASET): @@ -319,6 +323,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 @@ -328,12 +337,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( @@ -347,20 +350,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 ae896fcfeea..7d98522cd23 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -230,23 +230,27 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { bool OpenThreadComponent::teardown() { switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { - auto lock = InstanceLock::try_acquire(100); - if (!lock) { - // Try again on next teardown loop - ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown"); - return false; - } - // Start tearing down - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS; - ESP_LOGV(TAG, "Clear SRP"); - otInstance *instance = lock.get_instance(); - otSrpClientClearHostAndServices(instance); - otSrpClientBuffersFreeAllServices(instance); - if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to disable Thread during teardown"); - } - if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to disable IPv6 during teardown"); + { + auto lock = InstanceLock::try_acquire(100); + // The OT task may still be starting up; stay pending and retry on + // the next call rather than giving up after a single failed attempt. + if (!lock) { + ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown"); + return false; + } + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS; + ESP_LOGV(TAG, "Clear SRP"); + otInstance *instance = lock.get_instance(); + otSrpClientClearHostAndServices(instance); + otSrpClientBuffersFreeAllServices(instance); + if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to disable Thread during teardown"); + } + if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to disable IPv6 during teardown"); + } + // Release the lock before stopping -- openthread_stop_() (esp_openthread_stop() on + // ESP32) acquires it internally, and the lock is not recursive. } // Stop OpenThread global_openthread_component = nullptr; @@ -254,11 +258,11 @@ bool OpenThreadComponent::teardown() { int error = this->openthread_stop_(); if (error != 0) { ESP_LOGW(TAG, "Failed attempt to stop OpenThread %d", error); - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } } break; case TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS: - // Waiting on OpenThread stop + // Unreachable today; teardown is synchronous on both platforms. Kept for a future + // graceful-exit path, or a platform whose teardown() cannot be made synchronous. break; case TeardownStage::TEARDOWN_STAGE_COMPLETED: ESP_LOGV(TAG, "OpenThreadComponent Teardown Complete"); @@ -282,6 +286,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; setup() 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 b83ffdb6af6..8599304c89a 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(); @@ -37,11 +35,10 @@ class OpenThreadComponent final : public Component { float get_setup_priority() const override { return setup_priority::WIFI; } bool is_connected() const { return this->connected_; } - /// Returns true once esp_openthread_init() has completed and the OT lock is usable. + /// Returns true once esp_openthread_start() has completed and the OT lock is usable. bool is_lock_initialized() const { return this->lock_initialized_; } network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); - void ot_main(); void on_factory_reset(std::function callback); void defer_factory_reset_external_callback(); @@ -53,18 +50,15 @@ 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. */ void apply_linkmode_(otInstance *instance); @@ -77,7 +71,8 @@ class OpenThreadComponent final : public Component { #endif std::optional output_power_{}; std::atomic lock_initialized_{false}; - std::atomic teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; + // Only ever written from teardown(), on the main task -- no atomic needed. + TeardownStage teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED}; std::atomic connected_{false}; private: diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 881bbea3c99..526fc961f2f 100644 --- a/esphome/components/openthread/openthread_esp.cpp +++ b/esphome/components/openthread/openthread_esp.cpp @@ -1,6 +1,5 @@ #include "esphome/core/defines.h" #if defined(USE_OPENTHREAD) && defined(USE_ESP32) -#include #include "openthread.h" #include "esp_log.h" @@ -13,14 +12,9 @@ #include "esphome/core/log.h" #include "esp_err.h" -#include "esp_event.h" #include "esp_netif.h" -#include "esp_netif_types.h" -#include "esp_openthread_cli.h" #include "esp_openthread_netif_glue.h" #include "esp_vfs_eventfd.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" #include "nvs_flash.h" static const char *const TAG = "openthread"; @@ -39,140 +33,94 @@ void OpenThreadComponent::setup() { ESP_ERROR_CHECK(nvs_flash_init()); ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config)); - xTaskCreate( - [](void *arg) { - static_cast(arg)->ot_main(); - vTaskDelete(nullptr); - }, - "ot_main", 10240, this, 5, nullptr); -} + esp_openthread_config_t config = {.netif_config = ESP_NETIF_DEFAULT_OPENTHREAD(), + .platform_config = { + .radio_config = + { + .radio_mode = RADIO_MODE_NATIVE, + .radio_uart_config = {}, + }, + .host_config = + { + // There is a conflict between esphome's logger which also + // claims the usb serial jtag device. + // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, + // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), + }, + .port_config = + { + .storage_partition_name = "nvs", + .netif_queue_size = 10, + .task_queue_size = 10, + }, + }}; -static esp_netif_t *init_openthread_netif(const esp_openthread_platform_config_t *config) { - esp_netif_config_t cfg = ESP_NETIF_DEFAULT_OPENTHREAD(); - esp_netif_t *netif = esp_netif_new(&cfg); - assert(netif != nullptr); - ESP_ERROR_CHECK(esp_netif_attach(netif, esp_openthread_netif_glue_init(config))); - - return netif; -} - -void OpenThreadComponent::ot_main() { - esp_openthread_platform_config_t config = { - .radio_config = - { - .radio_mode = RADIO_MODE_NATIVE, - .radio_uart_config = {}, - }, - .host_config = - { - // There is a conflict between esphome's logger which also - // claims the usb serial jtag device. - // .host_connection_mode = HOST_CONNECTION_MODE_CLI_USB, - // .host_usb_config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(), - }, - .port_config = - { - .storage_partition_name = "nvs", - .netif_queue_size = 10, - .task_queue_size = 10, - }, - }; - - // Initialize the OpenThread stack - // otLoggingSetLevel(OT_LOG_LEVEL_DEBG); - ESP_ERROR_CHECK(esp_openthread_init(&config)); + ESP_ERROR_CHECK(esp_openthread_start(&config)); // Mark lock as initialized so InstanceLock callers know it's safe to acquire. - // Must be set after esp_openthread_init() which creates the internal semaphore. + // Must be set after esp_openthread_start() which creates the internal semaphore. this->lock_initialized_ = true; // Fetch OT instance once to avoid repeated call into OT stack otInstance *instance = esp_openthread_get_instance(); + { + InstanceLock lock = InstanceLock::acquire(); -#if CONFIG_OPENTHREAD_STATE_INDICATOR_ENABLE - ESP_ERROR_CHECK(esp_openthread_state_indicator_init(instance)); -#endif + this->apply_linkmode_(instance); -#if CONFIG_OPENTHREAD_LOG_LEVEL_DYNAMIC - // The OpenThread log level directly matches ESP log level - (void) otLoggingSetLevel(CONFIG_LOG_DEFAULT_LEVEL); -#endif - // Initialize the OpenThread cli -#if CONFIG_OPENTHREAD_CLI - esp_openthread_cli_init(); -#endif - - esp_netif_t *openthread_netif; - // Initialize the esp_netif bindings - openthread_netif = init_openthread_netif(&config); - esp_netif_set_default_netif(openthread_netif); - -#if CONFIG_OPENTHREAD_CLI_ESP_EXTENSION - esp_cli_custom_command_init(); -#endif // CONFIG_OPENTHREAD_CLI_ESP_EXTENSION - - ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); - - this->apply_linkmode_(instance); - - if (this->output_power_.has_value()) { - if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { - ESP_LOGE(TAG, "Failed to set power: %s", otThreadErrorToString(err)); + if (this->output_power_.has_value()) { + if (const auto err = otPlatRadioSetTransmitPower(instance, *this->output_power_); err != OT_ERROR_NONE) { + ESP_LOGE(TAG, "Failed to set power: %s", otThreadErrorToString(err)); + } } - } - - // Run the main loop -#if CONFIG_OPENTHREAD_CLI - esp_openthread_cli_create_task(); -#endif - ESP_LOGI(TAG, "Activating dataset..."); - otOperationalDatasetTlvs dataset = {}; + ESP_LOGI(TAG, "Activating dataset..."); + otOperationalDatasetTlvs dataset = {}; #ifndef USE_OPENTHREAD_FORCE_DATASET - // Check if openthread has a valid dataset from a previous execution - otError error = otDatasetGetActiveTlvs(instance, &dataset); - if (error != OT_ERROR_NONE) { - // Make sure the length is 0 so we fallback to the configuration - dataset.mLength = 0; - } else { - ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); - } + // Check if openthread has a valid dataset from a previous execution + otError error = otDatasetGetActiveTlvs(instance, &dataset); + if (error != OT_ERROR_NONE) { + // Make sure the length is 0 so we fallback to the configuration + dataset.mLength = 0; + } else { + ESP_LOGI(TAG, "Found existing dataset, ignoring config (force_dataset: true to override)"); + } #endif #ifdef USE_OPENTHREAD_TLVS - if (dataset.mLength == 0) { - // If we didn't have an active dataset, and we have tlvs, parse it and pass it to esp_openthread_auto_start - size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; - if (len > sizeof(dataset.mTlvs)) { - ESP_LOGW(TAG, "TLV buffer too small, truncating"); - len = sizeof(dataset.mTlvs); + if (dataset.mLength == 0) { + // If we didn't have an active dataset, and we have tlvs, parse it and pass it to esp_openthread_auto_start + size_t len = (sizeof(USE_OPENTHREAD_TLVS) - 1) / 2; + if (len > sizeof(dataset.mTlvs)) { + ESP_LOGW(TAG, "TLV buffer too small, truncating"); + len = sizeof(dataset.mTlvs); + } + parse_hex(USE_OPENTHREAD_TLVS, sizeof(USE_OPENTHREAD_TLVS) - 1, dataset.mTlvs, len); + dataset.mLength = len; } - parse_hex(USE_OPENTHREAD_TLVS, sizeof(USE_OPENTHREAD_TLVS) - 1, dataset.mTlvs, len); - dataset.mLength = len; - } #endif - // Pass the existing dataset, or NULL which will use the preprocessor definitions - ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); + // Pass the existing dataset, or NULL which will use the preprocessor definitions + ESP_ERROR_CHECK(esp_openthread_auto_start(dataset.mLength > 0 ? &dataset : nullptr)); - // Register state change callback to update connected_ reactively instead of polling - otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); - if (ot_err != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + // Register state change callback to update connected_ reactively instead of polling + otError ot_err = otSetStateChangedCallback(instance, OpenThreadComponent::on_state_changed, this); + if (ot_err != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to register state change callback: %d", ot_err); + } } - esp_openthread_launch_mainloop(); - - // Clean up - reset lock flag before deinit destroys the semaphore - this->lock_initialized_ = false; - esp_openthread_deinit(); - esp_openthread_netif_glue_deinit(); - esp_netif_destroy(openthread_netif); - - esp_vfs_eventfd_unregister(); - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; - vTaskDelete(NULL); + ESP_LOGD(TAG, "Thread Version: %" PRIu16, otThreadGetVersion()); } -int OpenThreadComponent::openthread_stop_() { return esp_openthread_mainloop_exit(); } +int OpenThreadComponent::openthread_stop_() { + // Clean up - reset lock flag before deinit destroys the semaphore + this->lock_initialized_ = false; + int error = esp_openthread_stop(); + // Mark complete even on failure: we're already mid-shutdown/reboot, so there's no + // recovery path to retry into -- leaving the stage stuck would only burn the full + // teardown timeout for no benefit. + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; + return error; +} network::IPAddresses OpenThreadComponent::get_ip_addresses() { network::IPAddresses addresses; @@ -199,8 +147,14 @@ InstanceLock InstanceLock::try_acquire(int delay) { } InstanceLock InstanceLock::acquire() { - // Wait for the lock to be created by ot_main() before attempting to acquire it. - // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_init(). + // teardown() clears global_openthread_component before the stack fully stops; a caller + // racing teardown would otherwise dereference a null pointer below. + if (global_openthread_component == nullptr) { + ESP_LOGE(TAG, "OpenThread component torn down, cannot acquire instance lock"); + abort(); + } + // Wait for the lock to be created before attempting to acquire it. + // esp_openthread_lock_acquire() will assert-crash if called before esp_openthread_start(). constexpr uint32_t lock_init_timeout_ms = 10000; uint32_t start = millis(); while (!global_openthread_component->is_lock_initialized()) { diff --git a/esphome/components/openthread/openthread_zephyr.cpp b/esphome/components/openthread/openthread_zephyr.cpp index cacb4c01226..0f474fc040c 100644 --- a/esphome/components/openthread/openthread_zephyr.cpp +++ b/esphome/components/openthread/openthread_zephyr.cpp @@ -83,9 +83,10 @@ void OpenThreadComponent::setup() { } openthread_state_changed_cb_register(context, &ot_state_changed_cb); openthread_start(context); -} -void OpenThreadComponent::ot_main() {} + InstanceLock lock = InstanceLock::acquire(); + this->apply_linkmode_(lock.get_instance()); +} otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); } diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index da789ae706a..4d3e8cd6c37 100644 --- a/esphome/components/openthread_info/text_sensor.py +++ b/esphome/components/openthread_info/text_sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import text_sensor +from esphome.components.const import CONF_ROLE from esphome.components.openthread.const import ( CONF_EXT_PAN_ID, CONF_NETWORK_KEY, @@ -10,7 +11,6 @@ import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_IP_ADDRESS, ENTITY_CATEGORY_DIAGNOSTIC from esphome.types import ConfigType -CONF_ROLE = "role" CONF_RLOC16 = "rloc16" CONF_EUI64 = "eui64" CONF_EXT_ADDR = "ext_addr" 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_backend.h b/esphome/components/ota/ota_backend.h index 7348a0ce902..a505d58efef 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -7,6 +7,7 @@ #include #include #include +#include #ifdef USE_OTA_STATE_LISTENER #include @@ -102,6 +103,8 @@ enum OTAType : uint8_t { // - set_update_md5: expected digest of the incoming image, hex string. // - write: consume the next chunk; end: finalize and mark bootable. // - abort: safe to call in any state, including after end(). +// - supports_compression: constexpr, whether a gzip image is stored as is and +// inflated at reboot. template concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *data, size_t len, const char *md5) { { backend.begin(image_size, OTA_TYPE_UPDATE_APP) } -> std::same_as; @@ -110,7 +113,9 @@ concept OTABackendContract = requires(T backend, size_t image_size, uint8_t *dat { backend.write(data, len) } -> std::same_as; { backend.end() } -> std::same_as; backend.abort(); - { backend.supports_compression() } -> std::same_as; + { T::supports_compression() } -> std::same_as; + // The value must be a constant expression + typename std::bool_constant; }; /** Listener interface for OTA state changes. diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c2716a44d1c..c322ed21f23 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -13,7 +13,7 @@ class ArduinoLibreTinyOTABackend final { OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } private: bool md5_set_{false}; diff --git a/esphome/components/ota/ota_backend_arduino_rp2.h b/esphome/components/ota/ota_backend_arduino_rp2.h index f7c0037bd2f..15142869ac3 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -15,7 +15,10 @@ class ArduinoRP2OTABackend final { OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); - bool supports_compression() { return false; } + // The core's OTA stub inflates a staged gzip image at reboot, on every chip + // from 4.0.3 (ESPHome pins 6.0.0). begin() only sees the gzip size; the + // inflated size is known when the stub reads the trailer. + static constexpr bool supports_compression() { return USE_ARDUINO_VERSION_CODE >= VERSION_CODE(4, 0, 3); } private: bool md5_set_{false}; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 21b5c12c2d6..1f1ec37eee4 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -20,7 +20,7 @@ class ESP8266OTABackend final { OTAResponseTypes end(); void abort(); // Compression supported in all ESP8266 Arduino versions ESPHome supports (>= 2.7.0) - bool supports_compression() { return true; } + static constexpr bool supports_compression() { return true; } protected: /// Erase flash sector if current address is at sector boundary diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index c991f896e82..4f4093a5945 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -33,7 +33,7 @@ class IDFOTABackend final { OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } protected: #ifdef USE_OTA_PARTITIONS diff --git a/esphome/components/ota/ota_backend_factory.h b/esphome/components/ota/ota_backend_factory.h index 82d001ed9eb..06c58582b51 100644 --- a/esphome/components/ota/ota_backend_factory.h +++ b/esphome/components/ota/ota_backend_factory.h @@ -25,7 +25,7 @@ struct StubOTABackend { OTAResponseTypes write(uint8_t *data, size_t len) { return OTA_RESPONSE_ERROR_UNKNOWN; } OTAResponseTypes end() { return OTA_RESPONSE_ERROR_UNKNOWN; } void abort() {} - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } }; std::unique_ptr make_ota_backend(); } // namespace esphome::ota @@ -33,6 +33,7 @@ std::unique_ptr make_ota_backend(); namespace esphome::ota { using OTABackendPtr = decltype(make_ota_backend()); -static_assert(OTABackendContract, +using OTABackend = OTABackendPtr::element_type; +static_assert(OTABackendContract, "The platform's OTA backend is missing part of the backend surface (ota_backend.h)"); } // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 51ffdaeda3e..e53868f102b 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -19,7 +19,7 @@ class HostOTABackend final { OTAResponseTypes write(uint8_t *data, size_t len); OTAResponseTypes end(); void abort(); - bool supports_compression() { return false; } + static constexpr bool supports_compression() { return false; } protected: md5::MD5Digest md5_{}; diff --git a/esphome/components/output/__init__.py b/esphome/components/output/__init__.py index 10d5e5eb593..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: @@ -85,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/packages/__init__.py b/esphome/components/packages/__init__.py index 1a03e0659a4..534f7622e8f 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -163,7 +163,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/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 47b9118d9ae..9756e9431ab 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -60,7 +60,7 @@ def validate_mode(value: ConfigType) -> ConfigType: PCA6416A_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(PCA6416AGPIOPin), - cv.Required(CONF_PCA6416A): cv.use_id(PCA6416AComponent), + cv.Required(CONF_PCA6416A): pins.use_id_or_address(PCA6416AComponent), cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), cv.Optional(CONF_MODE, default={}): cv.All( { diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 5272df2b555..68e6d163e8d 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -66,7 +66,7 @@ PCA9554_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_PCA9554): cv.use_id(PCA9554Component), + cv.Required(CONF_PCA9554): pins.use_id_or_address(PCA9554Component), } ) 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/__init__.py b/esphome/components/pcf8574/__init__.py index 9f8c7fabd25..4ee1c2af567 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -63,7 +63,7 @@ PCF8574_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_PCF8574): cv.use_id(PCF8574Component), + cv.Required(CONF_PCF8574): pins.use_id_or_address(PCF8574Component), } ) diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 11a5dd62bb6..45d84b88efd 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -74,7 +74,7 @@ PI4IOE5V6408_PIN_SCHEMA = pins.gpio_base_schema( mode_validator=validate_mode, ).extend( { - cv.Required(CONF_PI4IOE5V6408): cv.use_id(PI4IOE5V6408Component), + cv.Required(CONF_PI4IOE5V6408): pins.use_id_or_address(PI4IOE5V6408Component), } ) 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/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index 447671ebb37..98c64066761 100644 --- a/esphome/components/pm1006/sensor.py +++ b/esphome/components/pm1006/sensor.py @@ -62,6 +62,5 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - 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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) diff --git a/esphome/components/pm2005/sensor.py b/esphome/components/pm2005/sensor.py index f16c100e5eb..a0c133eba10 100644 --- a/esphome/components/pm2005/sensor.py +++ b/esphome/components/pm2005/sensor.py @@ -74,14 +74,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_sensor_type(config[CONF_TYPE])) - if pm_1_0_config := config.get(CONF_PM_1_0): - sens = await sensor.new_sensor(pm_1_0_config) - cg.add(var.set_pm_1_0_sensor(sens)) - - if pm_2_5_config := config.get(CONF_PM_2_5): - sens = await sensor.new_sensor(pm_2_5_config) - cg.add(var.set_pm_2_5_sensor(sens)) - - if pm_10_0_config := config.get(CONF_PM_10_0): - sens = await sensor.new_sensor(pm_10_0_config) - 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) diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 0b5c72a94d2..f20b93fff80 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -15,24 +15,38 @@ static const uint8_t CHECKSUM_START_INDEX = COUNT_DATA_BYTES - 2; static const uint8_t COUNT_16_BIT_VALUES = (COUNT_PAYLOAD_LENGTH_BYTES + COUNT_PAYLOAD_BYTES) / 2; static const uint8_t START_CHARACTER_1 = 0x42; static const uint8_t START_CHARACTER_2 = 0x4D; -static const uint8_t READ_DATA_RETRY_COUNT = 3; + +// Timeout for determining when the device is ready for use, in milliseconds. +// The PMSA003I typically takes 2.3 seconds to perform its first measurement after a cold power up and +// I2C requests performed during that time will be NACKed. Use a slightly longer timeout to tolerate +// timing variation. Note that although we consider the device ready as soon as it responds to I2C +// requests, it may take 30 seconds or more for the data to stabilize according to the datasheet. +static const uint32_t READY_TIMEOUT_MS = 3000; + +// Poll interval for determining when the device is ready for use, in milliseconds. +static const uint32_t READY_POLL_INTERVAL_MS = 100; void PMSA003IComponent::setup() { - PM25AQIData data; - bool successful_read = this->read_data_(&data); + // Stop polling until the device is actually ready to prevent spurious I2C warnings during premature updates. + this->stop_poller(); + this->poll_until_ready_or_timeout_(millis()); +} - if (!successful_read) { - for (uint8_t i = 0; i < READ_DATA_RETRY_COUNT; i++) { - successful_read = this->read_data_(&data); - if (successful_read) { - break; - } - } +void PMSA003IComponent::poll_until_ready_or_timeout_(uint32_t start_time) { + // Check whether the device is responding and identifies itself as expected. + // We don't care about the actual sensor readings or the packet CRC in this case. + uint8_t buffer[2]; + if (this->read(buffer, sizeof(buffer)) == i2c::ERROR_OK && buffer[0] == START_CHARACTER_1 && + buffer[1] == START_CHARACTER_2) { + ESP_LOGD(TAG, "PMSA003I is ready"); + this->start_poller(); + return; } - if (!successful_read) { - this->mark_failed(); - return; + if (millis() - start_time < READY_TIMEOUT_MS) { + this->set_timeout(READY_POLL_INTERVAL_MS, [this, start_time]() { this->poll_until_ready_or_timeout_(start_time); }); + } else { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); } } diff --git a/esphome/components/pmsa003i/pmsa003i.h b/esphome/components/pmsa003i/pmsa003i.h index 908b073be1e..6eccde36659 100644 --- a/esphome/components/pmsa003i/pmsa003i.h +++ b/esphome/components/pmsa003i/pmsa003i.h @@ -46,6 +46,7 @@ class PMSA003IComponent final : public PollingComponent, public i2c::I2CDevice { void set_pmc_10_0_sensor(sensor::Sensor *pmc_10_0) { this->pmc_10_0_sensor_ = pmc_10_0; } protected: + void poll_until_ready_or_timeout_(uint32_t start_time); bool read_data_(PM25AQIData *data); bool standard_units_; 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/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..4cf88bafe9f 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) @@ -145,30 +140,17 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_falling_edge_mode(count[CONF_FALLING_EDGE])) cg.add(var.set_filter_us(config[CONF_INTERNAL_FILTER])) - if CONF_TOTAL in config: - sens = await sensor.new_sensor(config[CONF_TOTAL]) - cg.add(var.set_total_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL, var.set_total_sensor) -@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..e523ebe7569 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) @@ -95,30 +92,17 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_timeout_us(config[CONF_TIMEOUT])) cg.add(var.set_filter_mode(config[CONF_INTERNAL_FILTER_MODE])) - if CONF_TOTAL in config: - sens = await sensor.new_sensor(config[CONF_TOTAL]) - cg.add(var.set_total_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL, var.set_total_sensor) -@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/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index f303a142975..ebba97d1a3e 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -10,22 +10,21 @@ Once the API is considered stable, this warning will be removed. from esphome import automation import esphome.codegen as cg +from esphome.components import ir_rf_base import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_ON_CONTROL -from esphome.core import CORE, coroutine_with_priority -from esphome.core.entity_helpers import queue_entity_register, setup_entity +from esphome.core import coroutine_with_priority +from esphome.core.entity_helpers import setup_entity from esphome.coroutine import CoroPriority from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@kbx81"] -AUTO_LOAD = ["remote_base"] +AUTO_LOAD = ["ir_rf_base"] IS_PLATFORM_COMPONENT = True radio_frequency_ns = cg.esphome_ns.namespace("radio_frequency") -RadioFrequency = radio_frequency_ns.class_( - "RadioFrequency", cg.EntityBase, cg.Component -) +RadioFrequency = radio_frequency_ns.class_("RadioFrequency", ir_rf_base.IrRfEntity) RadioFrequencyCall = radio_frequency_ns.class_("RadioFrequencyCall") RadioFrequencyTraits = radio_frequency_ns.class_("RadioFrequencyTraits") RadioFrequencyModulation = radio_frequency_ns.enum("RadioFrequencyModulation") @@ -55,11 +54,8 @@ async def setup_radio_frequency_core_(var: cg.MockObj, config: ConfigType) -> No async def register_radio_frequency(var: cg.MockObj, config: ConfigType) -> None: """Register a radio frequency device with the core.""" - cg.add_define("USE_RADIO_FREQUENCY") - await cg.register_component(var, config) - queue_entity_register("radio_frequency", config) + await ir_rf_base.register_ir_rf_entity(var, config, "radio_frequency") await setup_radio_frequency_core_(var, config) - CORE.register_platform_component("radio_frequency", var) for conf in config.get(CONF_ON_CONTROL, []): await automation.build_callback_automation( diff --git a/esphome/components/radio_frequency/radio_frequency.cpp b/esphome/components/radio_frequency/radio_frequency.cpp index 61e7feb9af4..a2386032177 100644 --- a/esphome/components/radio_frequency/radio_frequency.cpp +++ b/esphome/components/radio_frequency/radio_frequency.cpp @@ -4,73 +4,17 @@ #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - namespace esphome::radio_frequency { static const char *const TAG = "radio_frequency"; -// ========== RadioFrequencyCall ========== - -RadioFrequencyCall &RadioFrequencyCall::set_frequency(uint32_t frequency_hz) { - this->frequency_hz_ = frequency_hz; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_modulation(RadioFrequencyModulation modulation) { - this->modulation_ = modulation; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings(const std::vector &timings) { - this->raw_timings_ = &timings; - this->packed_data_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_base64url(const std::string &base64url) { - this->base64url_ptr_ = &base64url; - this->raw_timings_ = nullptr; - this->packed_data_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { - this->packed_data_ = data; - this->packed_length_ = length; - this->packed_count_ = count; - this->raw_timings_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) { - this->repeat_count_ = count; - return *this; -} - -void RadioFrequencyCall::perform() { - if (this->parent_ != nullptr) { - // Fire any on_control hooks (user-wired automations) before handing off to - // the platform-specific control() — gives users a chance to react to call - // parameters (e.g. retune an external RF front-end based on call.get_frequency()). - this->parent_->control_callback_.call(*this); - this->parent_->control(*this); - } -} - -// ========== RadioFrequency ========== - void RadioFrequency::dump_config() { ESP_LOGCONFIG(TAG, "Radio Frequency '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); if (this->traits_.get_frequency_min_hz() > 0) { if (this->traits_.get_frequency_min_hz() == this->traits_.get_frequency_max_hz()) { ESP_LOGCONFIG(TAG, " Frequency: %" PRIu32 " Hz (fixed)", this->traits_.get_frequency_min_hz()); @@ -81,31 +25,9 @@ void RadioFrequency::dump_config() { } } -uint32_t RadioFrequency::get_capability_flags() const { - uint32_t flags = 0; - if (this->traits_.get_supports_transmitter()) - flags |= RadioFrequencyCapability::CAPABILITY_TRANSMITTER; - if (this->traits_.get_supports_receiver()) - flags |= RadioFrequencyCapability::CAPABILITY_RECEIVER; - return flags; -} - bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) { - // Invoke local callbacks this->receive_callback_.call(data); - - // Forward received RF data to API server -#if defined(USE_API) && defined(USE_RADIO_FREQUENCY) - if (api::global_api_server != nullptr) { -#ifdef USE_DEVICES - uint32_t device_id = this->get_device_id(); -#else - uint32_t device_id = 0; -#endif - api::global_api_server->send_infrared_rf_receive_event(device_id, this->get_object_id_hash(), &data.get_raw_data()); - } -#endif - return false; // Don't consume the event, allow other listeners to process it + return IrRfEntity::on_receive(data); } } // namespace esphome::radio_frequency diff --git a/esphome/components/radio_frequency/radio_frequency.h b/esphome/components/radio_frequency/radio_frequency.h index 8782c255f00..d084a6c0164 100644 --- a/esphome/components/radio_frequency/radio_frequency.h +++ b/esphome/components/radio_frequency/radio_frequency.h @@ -4,20 +4,12 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. -#include "esphome/core/component.h" -#include "esphome/core/entity_base.h" -#include "esphome/core/helpers.h" -#include "esphome/components/remote_base/remote_base.h" - -#include +#include "esphome/components/ir_rf_base/ir_rf_base.h" namespace esphome::radio_frequency { -/// Capability flags for individual radio frequency instances -enum RadioFrequencyCapability : uint32_t { - CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals - CAPABILITY_RECEIVER = 1 << 1, // Can receive signals -}; +using ir_rf_base::CAPABILITY_RECEIVER; +using ir_rf_base::CAPABILITY_TRANSMITTER; /// Modulation types supported by radio frequency implementations enum RadioFrequencyModulation : uint8_t { @@ -25,95 +17,36 @@ enum RadioFrequencyModulation : uint8_t { // Future: RADIO_FREQUENCY_MODULATION_FSK, RADIO_FREQUENCY_MODULATION_GFSK, etc. }; -/// Forward declarations class RadioFrequency; /// RadioFrequencyCall - Builder pattern for transmitting radio frequency signals -class RadioFrequencyCall { +class RadioFrequencyCall : public ir_rf_base::IrRfCall { public: - explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {} + explicit RadioFrequencyCall(RadioFrequency *parent) : IrRfCall(parent) {} /// Set the carrier frequency in Hz (e.g. 433920000 for 433.92 MHz) - RadioFrequencyCall &set_frequency(uint32_t frequency_hz); - + RadioFrequencyCall &set_frequency(uint32_t frequency_hz) { + this->frequency_hz_ = frequency_hz; + return *this; + } /// Set the modulation type (defaults to OOK) - RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation); - - // ===== Raw Timings Methods ===== - // All set_raw_timings_* methods store pointers/references to external data. - // The referenced data must remain valid until perform() completes. - // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous - // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! - - /// Set the raw timings from a vector (positive = mark, negative = space) - /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). - /// @note Usage: Primarily for lambdas/automations where the vector is in scope. - RadioFrequencyCall &set_raw_timings(const std::vector &timings); - - /// Set the raw timings from base64url-encoded little-endian int32 data - /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). - /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). - /// @note Decoding happens at perform() time, directly into the transmit buffer. - RadioFrequencyCall &set_raw_timings_base64url(const std::string &base64url); - - /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) - /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). - /// @note Usage: For API component where data comes directly from the protobuf message. - RadioFrequencyCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); - - /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) - RadioFrequencyCall &set_repeat_count(uint32_t count); - - /// Perform the transmission - void perform(); - + RadioFrequencyCall &set_modulation(RadioFrequencyModulation modulation) { + this->modulation_ = modulation; + return *this; + } /// Get the frequency in Hz const optional &get_frequency() const { return this->frequency_hz_; } /// Get the modulation type RadioFrequencyModulation get_modulation() const { return this->modulation_; } - /// Get the raw timings (only valid if set via set_raw_timings) - const std::vector &get_raw_timings() const { return *this->raw_timings_; } - /// Check if raw timings have been set (any format) - bool has_raw_timings() const { - return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; - } - /// Check if using packed data format - bool is_packed() const { return this->packed_data_ != nullptr; } - /// Check if using base64url data format - bool is_base64url() const { return this->base64url_ptr_ != nullptr; } - /// Get the base64url data string - const std::string &get_base64url_data() const { return *this->base64url_ptr_; } - /// Get packed data (only valid if set via set_raw_timings_packed) - const uint8_t *get_packed_data() const { return this->packed_data_; } - uint16_t get_packed_length() const { return this->packed_length_; } - uint16_t get_packed_count() const { return this->packed_count_; } - /// Get the repeat count - uint32_t get_repeat_count() const { return this->repeat_count_; } protected: optional frequency_hz_{}; - uint32_t repeat_count_{1}; - RadioFrequency *parent_; - // Pointer to vector-based timings (caller-owned, must outlive perform()) - const std::vector *raw_timings_{nullptr}; - // Pointer to base64url-encoded string (caller-owned, must outlive perform()) - const std::string *base64url_ptr_{nullptr}; - // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) - const uint8_t *packed_data_{nullptr}; - uint16_t packed_length_{0}; - uint16_t packed_count_{0}; RadioFrequencyModulation modulation_{RADIO_FREQUENCY_MODULATION_OOK}; }; /// RadioFrequencyTraits - Describes the capabilities of a radio frequency implementation class RadioFrequencyTraits { public: - bool get_supports_transmitter() const { return this->supports_transmitter_; } - void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } - - bool get_supports_receiver() const { return this->supports_receiver_; } - void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } - /// Hardware-supported tunable frequency range in Hz. /// If min == max (and both non-zero): fixed-frequency hardware. /// If both 0: range unspecified. @@ -140,17 +73,14 @@ class RadioFrequencyTraits { uint32_t frequency_min_hz_{0}; // Minimum tunable frequency in Hz (0 = unspecified) uint32_t frequency_max_hz_{0}; // Maximum tunable frequency in Hz (0 = unspecified) uint32_t supported_modulations_{0}; // Bitmask of supported RadioFrequencyModulation values - bool supports_transmitter_{false}; - bool supports_receiver_{false}; }; /// RadioFrequency - Base class for radio frequency implementations -class RadioFrequency : public Component, public EntityBase, public remote_base::RemoteReceiverListener { +class RadioFrequency : public ir_rf_base::IrRfEntity { public: RadioFrequency() = default; void dump_config() override; - float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } /// Get the traits for this radio frequency implementation RadioFrequencyTraits &get_traits() { return this->traits_; } @@ -159,9 +89,6 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: /// Create a call object for transmitting RadioFrequencyCall make_call() { return RadioFrequencyCall(this); } - /// Get capability flags for this radio frequency instance - uint32_t get_capability_flags() const; - /// Called when RF data is received (from RemoteReceiverListener) bool on_receive(remote_base::RemoteReceiveData data) override; @@ -180,11 +107,16 @@ class RadioFrequency : public Component, public EntityBase, public remote_base:: } protected: - friend class RadioFrequencyCall; + friend class ir_rf_base::IrRfCall; + /// Fires the on_control hooks before the platform-specific control() runs + void on_call_(const RadioFrequencyCall &call) { this->control_callback_.call(call); } /// Perform the actual transmission (called by RadioFrequencyCall::perform()) /// Platforms must override this to implement hardware-specific transmission. - virtual void control(const RadioFrequencyCall &call) = 0; + /// Returns false only when no frame was handed to the transmitter, in which case no + /// completion follows. + /// Without a remote_base transmitter, call api_transmit_done_() once the frame is out + virtual bool control(const RadioFrequencyCall &call) = 0; // Traits describing capabilities RadioFrequencyTraits traits_; diff --git a/esphome/components/radon_eye_rd200/sensor.py b/esphome/components/radon_eye_rd200/sensor.py index f38a0352265..0e691e382da 100644 --- a/esphome/components/radon_eye_rd200/sensor.py +++ b/esphome/components/radon_eye_rd200/sensor.py @@ -47,9 +47,6 @@ async def to_code(config: ConfigType) -> None: await ble_client.register_ble_node(var, config) - if CONF_RADON in config: - sens = await sensor.new_sensor(config[CONF_RADON]) - cg.add(var.set_radon(sens)) - if CONF_RADON_LONG_TERM in config: - sens = await sensor.new_sensor(config[CONF_RADON_LONG_TERM]) - cg.add(var.set_radon_long_term(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RADON, var.set_radon) + await sensors(CONF_RADON_LONG_TERM, var.set_radon_long_term) diff --git a/esphome/components/rd03d/binary_sensor.py b/esphome/components/rd03d/binary_sensor.py index 2c040d05609..9971c413bd1 100644 --- a/esphome/components/rd03d/binary_sensor.py +++ b/esphome/components/rd03d/binary_sensor.py @@ -30,9 +30,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_ID]) - if target_config := config.get(CONF_TARGET): - sens = await binary_sensor.new_binary_sensor(target_config) - cg.add(hub.set_target_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_TARGET, hub.set_target_binary_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/rd03d/sensor.py b/esphome/components/rd03d/sensor.py index d29656bab04..fe47e5ae32d 100644 --- a/esphome/components/rd03d/sensor.py +++ b/esphome/components/rd03d/sensor.py @@ -79,9 +79,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_RD03D_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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index bf8707ff1e8..e9708d0a9f9 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -47,7 +47,7 @@ from esphome.const import ( 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 +from esphome.types import ConfigType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] @@ -136,8 +136,8 @@ async def attach_receiver( add_listener(receiver, var) -async def register_transmittable(var, config): - transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) +async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID): + transmitter_ = await cg.get_variable(config[key]) cg.add(var.set_transmitter(transmitter_)) @@ -1149,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_)) @@ -1538,9 +1538,7 @@ def validate_rc_switch_raw_code(value): return value -def build_rc_switch_protocol(config): - if isinstance(config, int): - return rc_switch_protocol(config) +def build_custom_rc_switch_protocol(config: ConfigType) -> MockObj: pl = config[CONF_PULSE_LENGTH] return RCSwitchBase( config[CONF_SYNC][0] * pl, @@ -1553,6 +1551,24 @@ def build_rc_switch_protocol(config): ) +def rc_switch_protocol_in_flash(config: int | ConfigType) -> MockObj: + """Pointer to the protocol in flash: a built-in table entry or a shared custom table.""" + if isinstance(config, int): + return cg.RawExpression(f"&{RC_SWITCH_PROTOCOLS}[{config}]") + return cg.shared_progmem_array( + "rc_switch_custom_protocol", + RCSwitchBase, + [build_custom_rc_switch_protocol(config)], + ) + + +def rc_switch_protocol_value(config: int | ConfigType) -> MockObj: + """RAM copy of a constant protocol for the transmit actions, read from its flash table.""" + if isinstance(config, int): + return rc_switch_protocol(config) + return rc_switch_protocol_copy(rc_switch_protocol_in_flash(config)) + + RC_SWITCH_RAW_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): validate_rc_switch_raw_code, @@ -1628,6 +1644,8 @@ RC_SWITCH_TRANSMITTER = cv.Schema( ) rc_switch_protocol = ns.rc_switch_protocol +rc_switch_protocol_copy = ns.rc_switch_protocol_copy +RC_SWITCH_PROTOCOLS = ns.RC_SWITCH_PROTOCOLS RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) @@ -1642,7 +1660,7 @@ RCSwitchRawReceiver = ns.class_("RCSwitchRawReceiver", RemoteReceiverBinarySenso @register_binary_sensor("rc_switch_raw", RCSwitchRawReceiver, RC_SWITCH_RAW_SCHEMA) def rc_switch_raw_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_code(config[CONF_CODE])) @@ -1653,7 +1671,7 @@ def rc_switch_raw_binary_sensor(var, config): ) async def rc_switch_raw_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_code(await cg.templatable(config[CONF_CODE], args, cg.std_string))) @@ -1663,7 +1681,7 @@ async def rc_switch_raw_action(var, config, args): "rc_switch_type_a", RCSwitchRawReceiver, RC_SWITCH_TYPE_A_SCHEMA ) def rc_switch_type_a_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_type_a(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) @@ -1674,7 +1692,7 @@ def rc_switch_type_a_binary_sensor(var, config): ) async def rc_switch_type_a_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) @@ -1688,7 +1706,7 @@ async def rc_switch_type_a_action(var, config, args): "rc_switch_type_b", RCSwitchRawReceiver, RC_SWITCH_TYPE_B_SCHEMA ) def rc_switch_type_b_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add( var.set_type_b(config[CONF_ADDRESS], config[CONF_CHANNEL], config[CONF_STATE]) ) @@ -1701,7 +1719,7 @@ def rc_switch_type_b_binary_sensor(var, config): ) async def rc_switch_type_b_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint8))) @@ -1713,7 +1731,7 @@ async def rc_switch_type_b_action(var, config, args): "rc_switch_type_c", RCSwitchRawReceiver, RC_SWITCH_TYPE_C_SCHEMA ) def rc_switch_type_c_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add( var.set_type_c( config[CONF_FAMILY], @@ -1731,7 +1749,7 @@ def rc_switch_type_c_binary_sensor(var, config): ) async def rc_switch_type_c_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add( @@ -1748,7 +1766,7 @@ async def rc_switch_type_c_action(var, config, args): RC_SWITCH_TYPE_D_SCHEMA.extend(RC_SWITCH_TRANSMITTER), ) def rc_switch_type_d_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_type_d(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) @@ -1759,7 +1777,7 @@ def rc_switch_type_d_binary_sensor(var, config): ) async def rc_switch_type_d_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) @@ -2106,6 +2124,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/dish_protocol.cpp b/esphome/components/remote_base/dish_protocol.cpp index 9a6420afd56..5a89c91a50e 100644 --- a/esphome/components/remote_base/dish_protocol.cpp +++ b/esphome/components/remote_base/dish_protocol.cpp @@ -20,7 +20,7 @@ void DishProtocol::encode(RemoteTransmitData *dst, const DishData &data) { // Typically a DISH device needs to get a command a total of // at least 4 times to accept it. - for (uint i = 0; i < 4; i++) { + for (uint32_t i = 0; i < 4; i++) { // COMMAND (function, in MSB) for (uint8_t mask = 1UL << 5; mask; mask >>= 1) { if (data.command & mask) { @@ -39,7 +39,7 @@ void DishProtocol::encode(RemoteTransmitData *dst, const DishData &data) { } } // PADDING - for (uint j = 0; j < 6; j++) + for (uint32_t j = 0; j < 6; j++) dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); // FOOTER @@ -73,7 +73,7 @@ optional DishProtocol::decode(RemoteReceiveData src) { return {}; } } - for (uint j = 0; j < 6; j++) { + for (uint32_t j = 0; j < 6; j++) { if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { return {}; } 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/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index de16c55cb02..bdd6615d432 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -9,12 +9,16 @@ namespace esphome::remote_base { static const char *const TAG = "remote.rc_switch"; 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; + return rc_switch_protocol_copy(&RC_SWITCH_PROTOCOLS[index]); +} + +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol) { + RCSwitchBase copy; + progmem_memcpy(©, protocol, sizeof(copy)); + return copy; } void RCSwitchBase::one(RemoteTransmitData *dst) const { @@ -232,7 +236,7 @@ uint64_t decode_binary_string_mask(const std::string &data) { bool RCSwitchRawReceiver::matches(RemoteReceiveData src) { uint64_t decoded_code; uint8_t decoded_nbits; - if (!this->protocol_.decode(src, &decoded_code, &decoded_nbits)) + if (!this->protocol_->decode(src, &decoded_code, &decoded_nbits)) return false; return decoded_nbits == this->nbits_ && (decoded_code & this->mask_) == (this->code_ & this->mask_); diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index 9ccea4d15a5..a102da3ff67 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/core/component.h" #include "remote_base.h" @@ -66,6 +68,9 @@ class RCSwitchBase { uint32_t one_high_{}; uint32_t one_low_{}; uint32_t inverted_{}; // bool widened so every field is a word: the table is read from flash + + // A bool here would still pad to 28 bytes, so the size check below alone would not catch it. + static_assert(std::is_same_v, "inverted_ must stay a word for flash reads"); }; // Constant-initialized and kept in flash on every platform. The decoder reads entries in place @@ -87,6 +92,9 @@ inline constexpr RCSwitchBase RC_SWITCH_PROTOCOLS[] PROGMEM = { /// 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); +/// RAM copy of a protocol stored in flash, made with progmem_memcpy (own name: `rc_switch_protocol(0)` stays +/// unambiguous) +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol); uint64_t decode_binary_string(const std::string &data); @@ -200,7 +208,8 @@ template class RCSwitchTypeDAction : public RemoteTransmitterAct class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { public: - void set_protocol(const RCSwitchBase &a_protocol) { this->protocol_ = a_protocol; } + /// `protocol` must outlive the receiver: a RC_SWITCH_PROTOCOLS entry or a codegen flash table. + void set_protocol(const RCSwitchBase *protocol) { this->protocol_ = protocol; } void set_code(uint64_t code) { this->code_ = code; } void set_code(const std::string &code) { this->code_ = decode_binary_string(code); @@ -228,7 +237,7 @@ class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { protected: bool matches(RemoteReceiveData src) override; - RCSwitchBase protocol_; + const RCSwitchBase *protocol_{nullptr}; // in flash; decoded in place (word-only fields) uint64_t code_; uint64_t mask_{0xFFFFFFFFFFFFFFFF}; uint8_t nbits_; diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 5d1bba16b61..a685b733b46 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -182,7 +182,7 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ -void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { +void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, [[maybe_unused]] uint16_t seq) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); char buffer[256]; @@ -213,6 +213,10 @@ void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { if (pos != 0) { ESP_LOGVV(TAG, "%s", buffer); } +#endif + this->flush_pending_completion(); +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + this->current_seq_ = seq; #endif this->send_internal(send_times, send_wait); } diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index 67e5799bcaf..36d2a44c509 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -143,6 +143,14 @@ class RemoteRMTChannel { #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 +class RemoteTransmitterBase; + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +/// Defined by ir_rf_base: answers the API request waiting on the entity that submitted seq; +/// sent is false when the platform never put the frame on the wire. +/// One function for the whole build instead of a callback list on every transmitter. +void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent); +#endif // 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. @@ -164,21 +172,24 @@ class RemoteTransmitterBase : public RemoteComponentBase { RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} class TransmitCall { public: - explicit TransmitCall(RemoteTransmitterBase *parent) : parent_(parent) {} + TransmitCall(RemoteTransmitterBase *parent, uint16_t seq) : parent_(parent), seq_(seq) {} RemoteTransmitData *get_data() { return &this->parent_->temp_; } void set_send_times(uint32_t send_times) { send_times_ = send_times; } void set_send_wait(uint32_t send_wait) { send_wait_ = send_wait; } - void perform() { this->parent_->send_(this->send_times_, this->send_wait_); } + /// Identifies this transmission in the completion hook + uint16_t get_seq() const { return this->seq_; } + void perform() { this->parent_->send_(this->send_times_, this->send_wait_, this->seq_); } protected: RemoteTransmitterBase *parent_; uint32_t send_times_{1}; uint32_t send_wait_{0}; + uint16_t seq_; }; TransmitCall transmit() { this->temp_.reset(); - return TransmitCall(this); + return TransmitCall(this, this->take_seq_()); } template void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { @@ -190,9 +201,25 @@ class RemoteTransmitterBase : public RemoteComponentBase { } protected: - void send_(uint32_t send_times, uint32_t send_wait); + void send_(uint32_t send_times, uint32_t send_wait, uint16_t seq); virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0; - void send_single_() { this->send_(1, 0); } + /// Platforms that report completion later wait out the previous frame here, before send_() + /// assigns the next seq, so that completion carries the seq it belongs to + virtual void flush_pending_completion() {} + void send_single_() { this->send_(1, 0, this->take_seq_()); } +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + /// Reports the frame handed to the platform last, after its final repeat and before the + /// on_complete trigger; a seq only has to be unique within the 30 s reply window + void notify_complete_(bool sent) { ir_rf_transmit_complete(this, this->current_seq_, sent); } + uint16_t take_seq_() { return ++this->next_seq_; } + + uint16_t next_seq_{0}; + uint16_t current_seq_{0}; +#else + // seq tracking only exists for the API completion reply + void notify_complete_(bool /*sent*/) {} + static uint16_t take_seq_() { return 0; } +#endif /// Use same vector for all transmits, avoids many allocations RemoteTransmitData temp_; diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 6f93979b183..1c1c473f478 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -88,8 +88,8 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, 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}; + bool with_dma_{false}; uint8_t carrier_duty_percent_{100}; #endif 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.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 5e82213a48d..29ef9bc2cc2 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -114,7 +114,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } } - this->complete_trigger_.trigger(); + this->fire_complete_(send_times != 0); // same answer as the ISR backend for a frame sent zero times } } // namespace esphome::remote_transmitter diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 99e1ce9504d..fad273a1185 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -82,6 +82,16 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; + // the API reply is answered before the user's on_complete automation; sent is false on a + // bail-out that never put the frame on the wire, on_complete fires either way + void fire_complete_(bool sent = true) { + this->notify_complete_(sent); + this->complete_trigger_.trigger(); + } +#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \ + defined(REMOTE_TRANSMITTER_BK_PWM) + void flush_pending_completion() override; +#endif #if defined(USE_ESP8266) || \ (defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \ defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp index fad91f593ff..26e9a1baa30 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -110,7 +110,7 @@ void RemoteTransmitterComponent::deliver_completion_() { if (!this->stall_aborted_) this->status_clear_warning(); this->complete_pending_ = false; - this->complete_trigger_.trigger(); + this->fire_complete_(!this->stall_aborted_); } // Waits until no chain is in flight, delivering any deferred completions; a completion @@ -152,20 +152,21 @@ void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_w this->start_isr_item_(0); } +void RemoteTransmitterComponent::flush_pending_completion() { this->wait_until_idle_(); } + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { if (!this->envelope_ready_()) { // both triggers still fire, so an on_complete-sequenced automation does not stall ESP_LOGW(TAG, "Cannot send: PWM not initialized"); this->transmit_trigger_.trigger(); - this->deliver_completion_(); + this->fire_complete_(false); return; } - this->wait_until_idle_(); if (send_times == 0) { // parity with the loop-based implementations: transmit nothing, but both triggers // still fire so an on_complete-sequenced automation does not stall this->transmit_trigger_.trigger(); - this->deliver_completion_(); + this->fire_complete_(false); return; } ESP_LOGD(TAG, "Sending remote code"); @@ -175,7 +176,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (this->isr_data_.empty()) { ESP_LOGW(TAG, "Empty data"); this->transmit_trigger_.trigger(); - this->deliver_completion_(); + this->fire_complete_(false); return; } // trigger first: the deadline computed in arm_chain_ must not be charged for user code diff --git a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp index 6d27be8d472..91bd339f210 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -200,25 +200,35 @@ void RemoteTransmitterComponent::wait_for_rmt_() { this->status_set_warning(); } - this->complete_trigger_.trigger(); + this->fire_complete_(error == ESP_OK); } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1) +void RemoteTransmitterComponent::flush_pending_completion() { + // a frame still on the wire is waited out, and its completion reported, before the next one + if (this->non_blocking_ && this->cancel_timeout("complete")) { + this->wait_for_rmt_(); + } +} + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { uint64_t total_duration = 0; if (this->is_failed()) { + // both triggers still fire, so a paced API client or on_complete automation is not left waiting + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } - // if the timeout was cancelled, block until the tx is complete - if (this->non_blocking_ && this->cancel_timeout("complete")) { - this->wait_for_rmt_(); - } - if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); this->configure_rmt_(); + if (this->is_failed()) { // the carrier change failed, there is no channel to send on + this->transmit_trigger_.trigger(); + this->fire_complete_(false); + return; + } } this->rmt_temp_.clear(); @@ -258,6 +268,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.size() <= offset) { ESP_LOGE(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } @@ -273,9 +285,11 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); this->status_set_warning(); - } else { - this->status_clear_warning(); + // nothing was queued, so there is no frame to wait for + this->fire_complete_(false); + return; } + this->status_clear_warning(); if (this->non_blocking_) { this->set_timeout("complete", total_duration / 1000, [this]() { this->wait_for_rmt_(); }); @@ -284,13 +298,23 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } #else +void RemoteTransmitterComponent::flush_pending_completion() {} + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - if (this->is_failed()) + if (this->is_failed()) { + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; + } if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); this->configure_rmt_(); + if (this->is_failed()) { // the carrier change failed, there is no channel to send on + this->transmit_trigger_.trigger(); + this->fire_complete_(false); + return; + } } this->rmt_temp_.clear(); @@ -328,9 +352,12 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.empty()) { ESP_LOGE(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } this->transmit_trigger_.trigger(); + bool sent = send_times != 0; // same answer as the ISR backend for a frame sent zero times for (uint32_t i = 0; i < send_times; i++) { rmt_transmit_config_t config; memset(&config, 0, sizeof(config)); @@ -340,18 +367,21 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_transmit failed: %s", esp_err_to_name(error)); this->status_set_warning(); - } else { - this->status_clear_warning(); + sent = false; } error = rmt_tx_wait_all_done(this->channel_, -1); if (error != ESP_OK) { ESP_LOGW(TAG, "rmt_tx_wait_all_done failed: %s", esp_err_to_name(error)); this->status_set_warning(); + sent = false; } if (i + 1 < send_times) delayMicroseconds(send_wait); } - this->complete_trigger_.trigger(); + // a later repeat must not clear the warning a failed one raised + if (sent) + this->status_clear_warning(); + this->fire_complete_(sent); } #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index 6db9faac368..799f46401c6 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -146,6 +146,8 @@ void RemoteTransmitterComponent::await_target_time_() { void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { if (this->pwm_ == nullptr) { ESP_LOGW(TAG, "Cannot send: PWM not initialized"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } ESP_LOGD(TAG, "Sending remote code"); @@ -194,7 +196,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen } } } - this->complete_trigger_.trigger(); + this->fire_complete_(); } #endif // USE_LIBRETINY_VARIANT_RTL8720C diff --git a/esphome/components/resampler/__init__.py b/esphome/components/resampler/__init__.py index e69de29bb2d..b9b070e0154 100644 --- a/esphome/components/resampler/__init__.py +++ b/esphome/components/resampler/__init__.py @@ -0,0 +1,15 @@ +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv + +resampler_ns = cg.esphome_ns.namespace("resampler") + +CONF_TAPS = "taps" + + +def validate_taps(taps: Any) -> int: + value = cv.int_range(min=16, max=128)(taps) + if value % 4 != 0: + raise cv.Invalid("Number of taps must be divisible by 4") + return value diff --git a/esphome/components/resampler/microphone/__init__.py b/esphome/components/resampler/microphone/__init__.py new file mode 100644 index 00000000000..0a8d0c1ca3a --- /dev/null +++ b/esphome/components/resampler/microphone/__init__.py @@ -0,0 +1,77 @@ +import esphome.codegen as cg +from esphome.components import audio, microphone +import esphome.config_validation as cv +from esphome.const import ( + CONF_BITS_PER_SAMPLE, + CONF_CHANNELS, + CONF_FILTERS, + CONF_ID, + CONF_MICROPHONE, + CONF_SAMPLE_RATE, + PLATFORM_ESP32, +) +from esphome.types import ConfigType + +from .. import CONF_TAPS, resampler_ns, validate_taps + +AUTO_LOAD = ["audio"] +DEPENDENCIES = ["microphone"] + +ResamplerMicrophone = resampler_ns.class_( + "ResamplerMicrophone", cg.Component, microphone.Microphone +) + + +def _set_stream_limits(config: ConfigType) -> ConfigType: + # Only the sample rate changes; the bits and channels are those selected from the source microphone + source = config[CONF_MICROPHONE] + audio.set_stream_limits( + min_bits_per_sample=source[CONF_BITS_PER_SAMPLE], + max_bits_per_sample=source[CONF_BITS_PER_SAMPLE], + min_channels=len(source[CONF_CHANNELS]), + max_channels=len(source[CONF_CHANNELS]), + min_sample_rate=config[CONF_SAMPLE_RATE], + max_sample_rate=config[CONF_SAMPLE_RATE], + )(config) + return config + + +CONFIG_SCHEMA = cv.All( + microphone.MICROPHONE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(ResamplerMicrophone), + cv.Required(CONF_MICROPHONE): microphone.microphone_source_schema( + min_bits_per_sample=16, + max_bits_per_sample=32, + min_channels=1, + max_channels=2, + ), + cv.Optional(CONF_SAMPLE_RATE, default=16000): cv.int_range(8000, 48000), + cv.Optional(CONF_FILTERS, default=16): cv.int_range(min=2, max=1024), + cv.Optional(CONF_TAPS, default=16): validate_taps, + } + ).extend(cv.COMPONENT_SCHEMA), + cv.only_on([PLATFORM_ESP32]), + _set_stream_limits, +) + + +FINAL_VALIDATE_SCHEMA = cv.Schema( + { + cv.Required( + CONF_MICROPHONE + ): microphone.final_validate_microphone_source_schema("resampler"), + }, + extra=cv.ALLOW_EXTRA, +) + + +async def to_code(config: ConfigType) -> None: + mic_source = await microphone.microphone_source_to_code(config[CONF_MICROPHONE]) + var = cg.new_Pvariable(config[CONF_ID], mic_source) + await cg.register_component(var, config) + await microphone.register_microphone(var, config) + + cg.add(var.set_target_sample_rate(config[CONF_SAMPLE_RATE])) + cg.add(var.set_filters(config[CONF_FILTERS])) + cg.add(var.set_taps(config[CONF_TAPS])) diff --git a/esphome/components/resampler/microphone/resampler_microphone.cpp b/esphome/components/resampler/microphone/resampler_microphone.cpp new file mode 100644 index 00000000000..0dcd5233800 --- /dev/null +++ b/esphome/components/resampler/microphone/resampler_microphone.cpp @@ -0,0 +1,174 @@ +#include "resampler_microphone.h" + +#ifdef USE_ESP32 + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::resampler { + +static const char *const TAG = "resampler.microphone"; + +// Duration of audio the resampler converts per step; longer source chunks are processed in several steps +static constexpr uint32_t BUFFER_DURATION_MS = 16; + +void ResamplerMicrophone::setup() { + const audio::AudioStreamInfo input_stream_info = this->source_->get_audio_stream_info(); + this->audio_stream_info_ = audio::AudioStreamInfo(input_stream_info.get_bits_per_sample(), + input_stream_info.get_channels(), this->target_sample_rate_); + + // Allocate now for the expected source format; process_audio_ only sets up again if that format changes + if (!this->init_resampler_(input_stream_info)) { + this->mark_failed(); + return; + } + + this->source_->add_data_callback([this](const std::vector &data) { this->process_audio_(data); }); + + this->disable_loop(); +} + +void ResamplerMicrophone::dump_config() { + ESP_LOGCONFIG(TAG, + "Resampler Microphone:\n" + " Target Sample Rate: %" PRIu32 " Hz\n" + " Taps: %u\n" + " Filters: %u", + this->target_sample_rate_, this->taps_, this->filters_); +} + +void ResamplerMicrophone::start() { + if (this->is_failed() || this->active_listeners_ == UINT8_MAX) + return; + ++this->active_listeners_; + this->enable_loop(); +} + +void ResamplerMicrophone::stop() { + if (this->active_listeners_ == 0) + return; + --this->active_listeners_; + this->enable_loop(); +} + +void ResamplerMicrophone::loop() { + if (this->active_listeners_ == 0) { + if (this->state_ != microphone::STATE_STOPPED) { + this->source_->stop(); + this->state_ = microphone::STATE_STOPPED; + } + this->disable_loop(); + return; + } + + switch (this->state_) { + case microphone::STATE_STOPPED: + this->source_->start(); + this->state_ = microphone::STATE_STARTING; + break; + case microphone::STATE_STARTING: + if (this->source_->is_running()) { + this->state_ = microphone::STATE_RUNNING; + } + break; + case microphone::STATE_RUNNING: + // Follow the source if it restarts, e.g. after a driver error + if (!this->source_->is_running()) { + this->state_ = microphone::STATE_STARTING; + } + break; + case microphone::STATE_STOPPING: + break; + } +} + +bool ResamplerMicrophone::init_resampler_(const audio::AudioStreamInfo &input_stream_info) { + this->resampler_.reset(); + this->resampler_ready_ = false; + + if (input_stream_info.get_sample_rate() == this->target_sample_rate_) { + // The source already delivers the target sample rate, so its audio is passed through unchanged + this->input_stream_info_ = input_stream_info; + this->resampler_ready_ = true; + return true; + } + + const audio::AudioStreamInfo output_stream_info(input_stream_info.get_bits_per_sample(), + input_stream_info.get_channels(), this->target_sample_rate_); + + auto resampler = make_unique( + input_stream_info.ms_to_samples(BUFFER_DURATION_MS), output_stream_info.ms_to_samples(BUFFER_DURATION_MS)); + + esp_audio_libs::resampler::ResamplerConfiguration resample_config = { + .source_sample_rate = static_cast(input_stream_info.get_sample_rate()), + .target_sample_rate = static_cast(this->target_sample_rate_), + .source_bits_per_sample = input_stream_info.get_bits_per_sample(), + .target_bits_per_sample = input_stream_info.get_bits_per_sample(), + .channels = input_stream_info.get_channels(), + // Filters out frequencies above the new Nyquist limit when downsampling, to avoid aliasing + .use_pre_or_post_filter = this->target_sample_rate_ < input_stream_info.get_sample_rate(), + .subsample_interpolate = false, // Doubles the CPU load; more filters is a better alternative + .number_of_taps = this->taps_, + .number_of_filters = this->filters_, + }; + + if (!resampler->initialize(resample_config)) { + ESP_LOGE(TAG, "Not enough memory to resample"); + return false; + } + + this->output_buffer_.reserve(output_stream_info.ms_to_bytes(BUFFER_DURATION_MS)); + this->resampler_ = std::move(resampler); + // Only set on success, so a failed set up is retried with the next chunk + this->input_stream_info_ = input_stream_info; + this->resampler_ready_ = true; + return true; +} + +void ResamplerMicrophone::process_audio_(const std::vector &data) { + const audio::AudioStreamInfo input_stream_info = this->source_->get_audio_stream_info(); + if (input_stream_info != this->input_stream_info_) { + this->init_resampler_(input_stream_info); + } + if (!this->resampler_ready_) { + return; + } + + if (this->resampler_ == nullptr) { + this->data_callbacks_.call(data); + return; + } + + const size_t input_bytes_per_frame = input_stream_info.frames_to_bytes(1); + const uint32_t max_input_frames = input_stream_info.ms_to_frames(BUFFER_DURATION_MS); + // Both limits match the sizes the resampler's internal buffers were allocated with in init_resampler_ + const uint32_t max_output_frames = this->audio_stream_info_.ms_to_frames(BUFFER_DURATION_MS); + + const uint8_t *input = data.data(); + uint32_t input_frames = input_stream_info.bytes_to_frames(data.size()); + while (input_frames > 0) { + // Stays within the reserved capacity, so this never reallocates + this->output_buffer_.resize(this->audio_stream_info_.frames_to_bytes(max_output_frames)); + + // The resampler's internal buffers hold at most BUFFER_DURATION_MS of audio, so feed it in steps of that size. + // 0 dB keeps the microphone level that downstream detectors are tuned for; overshoot saturates instead of wrapping. + esp_audio_libs::resampler::ResamplerResults results = this->resampler_->resample( + input, this->output_buffer_.data(), std::min(input_frames, max_input_frames), max_output_frames, 0.0f); + + input += results.frames_used * input_bytes_per_frame; + input_frames -= results.frames_used; + + if (results.frames_generated > 0) { + this->output_buffer_.resize(this->audio_stream_info_.frames_to_bytes(results.frames_generated)); + this->data_callbacks_.call(this->output_buffer_); + } else if (results.frames_used == 0) { + break; // No progress; drop the rest of the chunk instead of spinning + } + } +} + +} // namespace esphome::resampler + +#endif // USE_ESP32 diff --git a/esphome/components/resampler/microphone/resampler_microphone.h b/esphome/components/resampler/microphone/resampler_microphone.h new file mode 100644 index 00000000000..780f22e4294 --- /dev/null +++ b/esphome/components/resampler/microphone/resampler_microphone.h @@ -0,0 +1,62 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/components/audio/audio.h" +#include "esphome/components/microphone/microphone.h" +#include "esphome/components/microphone/microphone_source.h" + +#include "esphome/core/component.h" + +#include // esp-audio-libs + +#include +#include + +namespace esphome::resampler { + +/// @brief Microphone that converts the audio of a source microphone to a different sample rate. +/// The bits per sample and channels are selected by the source's ``MicrophoneSource``; only the sample rate changes. +/// Resampling runs in the source microphone's data callback, so it needs no task or ring buffer of its own. +class ResamplerMicrophone final : public Component, public microphone::Microphone { + public: + explicit ResamplerMicrophone(microphone::MicrophoneSource *source) : source_(source) {} + + void setup() override; + void loop() override; + void dump_config() override; + + void start() override; + void stop() override; + + void set_target_sample_rate(uint32_t target_sample_rate) { this->target_sample_rate_ = target_sample_rate; } + void set_filters(uint16_t filters) { this->filters_ = filters; } + void set_taps(uint16_t taps) { this->taps_ = taps; } + + protected: + /// @brief Sets up the resampler for the given input format. No resampler is needed if the sample rates match. + /// @return false if the resampler failed to allocate; the audio is then dropped + bool init_resampler_(const audio::AudioStreamInfo &input_stream_info); + + /// @brief Resamples a chunk of source audio and passes it to the data callbacks. Source microphone task only. + void process_audio_(const std::vector &data); + + microphone::MicrophoneSource *source_; + std::unique_ptr resampler_; + // Reused for every chunk so resampling does not allocate + std::vector output_buffer_; + + // Format the resampler is set up for + audio::AudioStreamInfo input_stream_info_; + + uint32_t target_sample_rate_; + uint16_t taps_; + uint16_t filters_; + + uint8_t active_listeners_{0}; + bool resampler_ready_{false}; +}; + +} // namespace esphome::resampler + +#endif // USE_ESP32 diff --git a/esphome/components/resampler/speaker/__init__.py b/esphome/components/resampler/speaker/__init__.py index 7de468cb502..8fa8aeb61c4 100644 --- a/esphome/components/resampler/speaker/__init__.py +++ b/esphome/components/resampler/speaker/__init__.py @@ -1,5 +1,3 @@ -from typing import Any - import esphome.codegen as cg from esphome.components import audio, psram, speaker import esphome.config_validation as cv @@ -17,16 +15,15 @@ from esphome.const import ( from esphome.core.entity_helpers import inherit_property_from from esphome.types import ConfigType +from .. import CONF_TAPS, resampler_ns, validate_taps + AUTO_LOAD = ["audio"] CODEOWNERS = ["@kahrendt"] -resampler_ns = cg.esphome_ns.namespace("resampler") ResamplerSpeaker = resampler_ns.class_( "ResamplerSpeaker", cg.Component, speaker.Speaker ) -CONF_TAPS = "taps" - PASSTHROUGH = "passthrough" @@ -60,13 +57,6 @@ def _validate_audio_compatibility(config: ConfigType) -> None: )(config) -def _validate_taps(taps: Any) -> int: - value = cv.int_range(min=16, max=128)(taps) - if value % 4 != 0: - raise cv.Invalid("Number of taps must be divisible by 4") - return value - - CONFIG_SCHEMA = cv.All( speaker.SPEAKER_SCHEMA.extend( { @@ -80,7 +70,7 @@ CONFIG_SCHEMA = cv.All( ): cv.positive_time_period_milliseconds, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_FILTERS, default=16): cv.int_range(min=2, max=1024), - cv.Optional(CONF_TAPS, default=16): _validate_taps, + cv.Optional(CONF_TAPS, default=16): validate_taps, } ).extend(cv.COMPONENT_SCHEMA), cv.only_on([PLATFORM_ESP32]), 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.h b/esphome/components/rf_bridge/rf_bridge.h index cbb1880ec53..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 { @@ -89,106 +88,4 @@ class RFBridgeComponent final : public uart::UARTDevice, public Component { 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/ring_buffer/ring_buffer.cpp b/esphome/components/ring_buffer/ring_buffer.cpp index 9604290cf03..dfcc53ebb2e 100644 --- a/esphome/components/ring_buffer/ring_buffer.cpp +++ b/esphome/components/ring_buffer/ring_buffer.cpp @@ -10,8 +10,9 @@ namespace esphome::ring_buffer { static const char *const TAG = "ring_buffer"; RingBuffer::~RingBuffer() { - if (this->handle_ != nullptr) { - vRingbufferDelete(this->handle_); + // create() only builds the ring buffer once storage_ is allocated + if (this->storage_ != nullptr) { + vRingbufferDelete(this->handle_()); RAMAllocator allocator; allocator.deallocate(this->storage_, this->size_); } @@ -31,7 +32,12 @@ std::unique_ptr RingBuffer::create(size_t len, MemoryPreference pref return nullptr; } - rb->handle_ = xRingbufferCreateStatic(rb->size_, RINGBUF_TYPE_BYTEBUF, rb->storage_, &rb->structure_); + // handle_() assumes the returned handle is &structure_; NULL means the arguments were rejected + if (xRingbufferCreateStatic(rb->size_, RINGBUF_TYPE_BYTEBUF, rb->storage_, &rb->structure_) != rb->handle_()) { + allocator.deallocate(rb->storage_, rb->size_); + rb->storage_ = nullptr; + return nullptr; + } ESP_LOGD(TAG, "Created ring buffer with size %u", len); return rb; @@ -39,16 +45,16 @@ std::unique_ptr RingBuffer::create(size_t len, MemoryPreference pref void *RingBuffer::receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait) { length = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &length, ticks_to_wait, max_length); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &length, ticks_to_wait, max_length); return buffer_data; } -void RingBuffer::receive_release(void *item) { vRingbufferReturnItem(this->handle_, item); } +void RingBuffer::receive_release(void *item) { vRingbufferReturnItem(this->handle_(), item); } size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { size_t bytes_read = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &bytes_read, ticks_to_wait, len); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &bytes_read, ticks_to_wait, len); if (buffer_data == nullptr) { return 0; @@ -56,14 +62,14 @@ size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { std::memcpy(data, buffer_data, bytes_read); - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); if (bytes_read < len) { // Data may have wrapped around, so read a second time to receive the remainder size_t follow_up_bytes_read = 0; size_t bytes_remaining = len - bytes_read; - buffer_data = xRingbufferReceiveUpTo(this->handle_, &follow_up_bytes_read, 0, bytes_remaining); + buffer_data = xRingbufferReceiveUpTo(this->handle_(), &follow_up_bytes_read, 0, bytes_remaining); if (buffer_data == nullptr) { return bytes_read; @@ -71,7 +77,7 @@ size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) { std::memcpy((void *) ((uint8_t *) (data) + bytes_read), buffer_data, follow_up_bytes_read); - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); bytes_read += follow_up_bytes_read; } @@ -89,13 +95,13 @@ size_t RingBuffer::write(const void *data, size_t len) { size_t RingBuffer::write_without_replacement(const void *data, size_t len, TickType_t ticks_to_wait, bool write_partial) { - if (!xRingbufferSend(this->handle_, data, len, ticks_to_wait)) { + if (!xRingbufferSend(this->handle_(), data, len, ticks_to_wait)) { if (!write_partial) { return 0; // Not enough space available and not allowed to write partial data } // Couldn't fit all the data, write what will fit size_t free = std::min(this->free(), len); - if (xRingbufferSend(this->handle_, data, free, 0)) { + if (xRingbufferSend(this->handle_(), data, free, 0)) { return free; } return 0; @@ -105,11 +111,11 @@ size_t RingBuffer::write_without_replacement(const void *data, size_t len, TickT size_t RingBuffer::available() const { UBaseType_t ux_items_waiting = 0; - vRingbufferGetInfo(this->handle_, nullptr, nullptr, nullptr, nullptr, &ux_items_waiting); + vRingbufferGetInfo(this->handle_(), nullptr, nullptr, nullptr, nullptr, &ux_items_waiting); return ux_items_waiting; } -size_t RingBuffer::free() const { return xRingbufferGetCurFreeSize(this->handle_); } +size_t RingBuffer::free() const { return xRingbufferGetCurFreeSize(this->handle_()); } BaseType_t RingBuffer::reset() { // Discards all the available data @@ -119,15 +125,15 @@ BaseType_t RingBuffer::reset() { bool RingBuffer::discard_bytes_(size_t discard_bytes) { size_t bytes_read = 0; - void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &bytes_read, 0, discard_bytes); + void *buffer_data = xRingbufferReceiveUpTo(this->handle_(), &bytes_read, 0, discard_bytes); if (buffer_data != nullptr) - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); if (bytes_read < discard_bytes) { size_t wrapped_bytes_read = 0; - buffer_data = xRingbufferReceiveUpTo(this->handle_, &wrapped_bytes_read, 0, discard_bytes - bytes_read); + buffer_data = xRingbufferReceiveUpTo(this->handle_(), &wrapped_bytes_read, 0, discard_bytes - bytes_read); if (buffer_data != nullptr) { - vRingbufferReturnItem(this->handle_, buffer_data); + vRingbufferReturnItem(this->handle_(), buffer_data); bytes_read += wrapped_bytes_read; } } diff --git a/esphome/components/ring_buffer/ring_buffer.h b/esphome/components/ring_buffer/ring_buffer.h index 62094899d78..ed2d4b43456 100644 --- a/esphome/components/ring_buffer/ring_buffer.h +++ b/esphome/components/ring_buffer/ring_buffer.h @@ -115,7 +115,9 @@ class RingBuffer { /// @return True if all bytes were successfully discarded, false otherwise bool discard_bytes_(size_t discard_bytes); - RingbufHandle_t handle_{nullptr}; + // xRingbufferCreateStatic() returns the static structure itself as the handle + RingbufHandle_t handle_() const { return const_cast(&this->structure_); } + StaticRingbuffer_t structure_; uint8_t *storage_{nullptr}; size_t size_{0}; 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.h b/esphome/components/router/speaker/router_speaker.h index 31f3f906301..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" @@ -80,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 a1bbf6a3d66..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, 1, 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.1.0 update and the board -# JSON files it adds; replace with a release tag when one is cut +# 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, 1, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(6, 1, 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 a9ce11c33dc..08536fd0537 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1442,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, @@ -1479,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, @@ -2286,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", @@ -2304,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/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 699555f623f..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.1.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/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..055204571e1 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" @@ -83,14 +82,14 @@ class Rtttl final : public Component { uint16_t note_duration_{0}; /// The duration of a whole note in milliseconds. uint16_t wholenote_duration_; + /// The current state of the RTTTL player. + State state_{State::STOPPED}; /// The time in milliseconds since microcontroller boot when the last note was started. uint32_t last_note_start_time_; /// The frequency of the current note in Hz. uint32_t output_freq_{0}; /// The gain of the output. float gain_{0.6f}; - /// The current state of the RTTTL player. - State state_{State::STOPPED}; #ifdef USE_OUTPUT /// The output to write the sound to. @@ -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_apply_action( + "rx8130.write_time", RX8130_ACTION_SCHEMA, automation.ApplyCall("write_time()") +) -@automation.register_action( - "rx8130.read_time", - ReadAction, - automation.maybe_simple_id( - { - cv.GenerateID(): cv.use_id(RX8130Component), - } - ), - synchronous=True, +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/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 e0cc04e2cb7..00000000000 --- a/esphome/components/scd4x/automation.h +++ /dev/null @@ -1,31 +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: - // User provided, not "= default": `new(p) PerformForcedCalibrationAction()` would zero-fill .bss that is already - // zero. - PerformForcedCalibrationAction() {} - - 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..97b27af498d 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. @@ -263,27 +254,8 @@ template class ScriptExecuteAction, T } Script *script_; - 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_; + // Empty tuple for scripts without parameters; takes no space then + [[no_unique_address]] Args args_; }; /** Wait for a script to finish before continuing. 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/sensor.py b/esphome/components/sds011/sensor.py index e05d3b02e6c..fe1481df9a5 100644 --- a/esphome/components/sds011/sensor.py +++ b/esphome/components/sds011/sensor.py @@ -93,10 +93,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_update_interval_min(update_interval)) cg.add(var.set_rx_mode_only(config[CONF_RX_ONLY])) - 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_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_10_0, var.set_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..fa266fc81f2 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) @@ -98,7 +95,9 @@ def select_schema( @setup_entity("select") async def setup_select_core_(var, config, *, options: list[str]): - cg.add(var.traits.set_options(options)) + if options: + table = cg.shared_progmem_array("select_options", cg.const_char_ptr, options) + cg.add(var.traits.set_options_static(table, len(options))) for conf in config.get(CONF_ON_VALUE, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) @@ -128,6 +127,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 +146,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( @@ -191,14 +189,8 @@ async def select_set_index_to_code(config, action_id, template_arg, args): async def select_is_to_code(config, condition_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) if options := config.get(CONF_OPTIONS): - # List of constant options - # Create a constexpr and pass that with a template length - arr_id = ID( - f"{condition_id}_data", - is_declaration=True, - type=global_ns.namespace("constexpr char * const"), - ) - arg = cg.static_const_array(arr_id, cg.ArrayInitializer(*options)) + # Shared flash table of option pointers, length passed as a template argument + arg = cg.shared_progmem_array("select_is_options", cg.const_char_ptr, options) template_arg = TemplateArguments(len(options), *template_arg) else: # Lambda @@ -211,9 +203,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 +213,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/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index 67a5118646a..23d1f47cce1 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -2,13 +2,18 @@ namespace esphome::select { -void SelectTraits::set_options(const std::initializer_list &options) { this->options_ = options; } +// Runtime option lists are copied, since the argument may not outlive the select; one +// out of line copy keeps a single instance of the copy code. +void SelectTraits::set_options_copy_(const char *const *options, size_t count) { + this->options_.assign_copy(options, count); +} + +void SelectTraits::set_options(const std::initializer_list &options) { + this->set_options_copy_(options.begin(), options.size()); +} void SelectTraits::set_options(const FixedVector &options) { - this->options_.init(options.size()); - for (const auto &opt : options) { - this->options_.push_back(opt); - } + this->set_options_copy_(options.begin(), options.size()); } } // namespace esphome::select diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index e1b261bc96f..47997ac7f28 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -5,14 +5,28 @@ namespace esphome::select { +/// Option strings: a shared codegen table, or a copy of a runtime list. +using SelectOptions = ConstVector; + class SelectTraits { public: + SelectTraits() = default; + SelectTraits(const SelectTraits &) = delete; + SelectTraits &operator=(const SelectTraits &) = delete; + + /// Codegen only: points at a table that outlives the select. Call before any runtime set_options; + /// it does not free a previous copy (generated setup() runs before any lambda or automation). + void set_options_static(const char *const *options, size_t count) { this->options_.assign_static(options, count); } + /// Runtime lists: the pointer list is copied, as before; the strings must still outlive the select. + void set_options(const SelectOptions &options) { this->set_options_copy_(options.data(), options.size()); } void set_options(const std::initializer_list &options); void set_options(const FixedVector &options); - const FixedVector &get_options() const { return this->options_; } + const SelectOptions &get_options() const { return this->options_; } protected: - FixedVector options_; + void set_options_copy_(const char *const *options, size_t count); + + SelectOptions options_; }; } // namespace esphome::select 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 49bee109361..42dae464ede 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -21,8 +21,8 @@ from esphome.const import ( 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 @@ -109,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 @@ -240,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: 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_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6a9f1f18ba2..743d0bb62cf 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -8,8 +8,6 @@ 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 ( @@ -41,18 +39,6 @@ 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.""" @@ -144,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/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 58ec57c7681..15d358d50fb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -284,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 b00fdc436eb..ea09dafe689 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -181,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)); } @@ -305,6 +308,9 @@ class SendspinHub final : public Component, CallbackManager group_update_callbacks_{}; bool task_stack_in_psram_{false}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool mdns_advertised_{false}; // Last state requested from mdns +#endif // Requested client state, applied from loop(). Empty until the switch restores its state. std::optional enabled_; @@ -317,7 +323,6 @@ class SendspinHub final : public Component, #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE mdns::MDNSComponent *mdns_{nullptr}; - bool mdns_advertised_{false}; // Last state requested from mdns #endif }; diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 63f5f7ad28b..1f896640bfc 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,7 +1,8 @@ import esphome.codegen as cg from esphome.components import switch +from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG from esphome.types import ConfigType from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns @@ -12,14 +13,19 @@ 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.typed_schema( + { + CONF_ENABLED: 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), + }, + key=CONF_TYPE, + ), cv.only_on_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..2494e9a6d1f 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( @@ -70,9 +53,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if co2 := config.get(CONF_CO2): - sens = await sensor.new_sensor(co2) - cg.add(var.set_co2_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( @@ -82,41 +64,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/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index f6ff4711d40..3325c277722 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -65,14 +65,15 @@ bool SensirionI2CDevice::write_command_(uint16_t command, CommandLen command_len } bool SensirionI2CDevice::get_register_(uint16_t reg, CommandLen command_len, uint16_t *data, const uint8_t len, - const uint8_t delay_ms) { + const uint8_t delay_ms, uint8_t sensirion_options) { if (!this->write_command_(reg, command_len, nullptr, 0)) { ESP_LOGE(TAG, "Write failed: reg=0x%X (%d) err=%d,", reg, command_len, this->last_error_); return false; } delay(delay_ms); bool result = this->read_data(data, len); - if (!result) { + if (!result && + (this->last_error_ != i2c::ERROR_NOT_ACKNOWLEDGED || !(sensirion_options & SENSIRION_OPTION_READ_MAY_NACK))) { ESP_LOGE(TAG, "Read failed: reg=0x%X err=%d,", reg, this->last_error_); } return result; diff --git a/esphome/components/sensirion_common/i2c_sensirion.h b/esphome/components/sensirion_common/i2c_sensirion.h index 558fbdbb129..1b1cd2ba15a 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.h +++ b/esphome/components/sensirion_common/i2c_sensirion.h @@ -16,6 +16,9 @@ namespace esphome::sensirion_common { */ static const uint8_t CRC_POLYNOMIAL = 0x31; // default for Sensirion +/// When reading a register and the device reports NACK because the value is not ready yet, don't log it as an error. +static constexpr uint8_t SENSIRION_OPTION_READ_MAY_NACK = 1u << 0; + class SensirionI2CDevice : public i2c::I2CDevice { public: enum CommandLen : uint8_t { ADDR_8_BIT = 1, ADDR_16_BIT = 2 }; @@ -139,9 +142,11 @@ class SensirionI2CDevice : public i2c::I2CDevice { * @param data pointer to raw result * @param len number of words to read * @param delay milliseconds to to wait between sending the I2C command and reading the result + * @param sensirion_options options for the request * @return true if reading succeeded */ - bool get_register_(uint16_t reg, CommandLen command_len, uint16_t *data, uint8_t len, uint8_t delay); + bool get_register_(uint16_t reg, CommandLen command_len, uint16_t *data, uint8_t len, uint8_t delay, + uint8_t sensirion_options = 0); /** last error code from I2C operation */ diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 3b632a1847f..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") @@ -1014,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/filter.cpp b/esphome/components/sensor/filter.cpp index dbd6f4d34b8..9b9d7c033ae 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -450,7 +450,7 @@ optional ToNTCResistanceFilter::new_value(float value) { } double k = 273.15; // https://de.wikipedia.org/wiki/Steinhart-Hart-Gleichung#cite_note-stein2_s4-3 - double t = value + k; + double t = static_cast(value) + k; double y = (this->a_ - 1 / (t)) / (2 * this->c_); double x = sqrt(pow(this->b_ / (3 * this->c_), 3) + y * y); double resistance = exp(pow(x - y, 1 / 3.0) - pow(x + y, 1 / 3.0)); diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index b6e780fabdc..a8767280504 100644 --- a/esphome/components/serial_proxy/__init__.py +++ b/esphome/components/serial_proxy/__init__.py @@ -17,10 +17,12 @@ from dataclasses import dataclass from esphome import pins import esphome.codegen as cg from esphome.components import uart +from esphome.components.usb_uart import is_usb_uart_channel import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_NAME +from esphome.const import CONF_ID, CONF_NAME, CONF_UART_ID from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -38,6 +40,7 @@ SERIAL_PROXY_PORT_TYPES = { "TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL, "RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232, "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, + "USB_SERIAL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL, } CONF_DTR_PIN = "dtr_pin" @@ -73,6 +76,18 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + is_usb = is_usb_uart_channel(config[CONF_UART_ID], fv.full_config.get()) + if config[CONF_PORT_TYPE] == "USB_SERIAL" and not is_usb: + raise cv.Invalid( + f"{CONF_PORT_TYPE} USB_SERIAL requires {CONF_UART_ID} to be a usb_uart channel" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_serial_proxy_count_define() -> None: """Emit the SERIAL_PROXY_COUNT define once with the final instance count.""" @@ -88,6 +103,12 @@ async def to_code(config: ConfigType) -> None: cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_port_type(config[CONF_PORT_TYPE])) + # port_type names the electrical interface (a USB RS485 adapter is RS485), so every + # usb_uart channel reports USB identity whatever port type it declares + if is_usb_uart_channel(config[CONF_UART_ID], CORE.config): + channel = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_usb_channel(channel)) + cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY") cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 129745c1c91..16e0f0b984c 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -2,8 +2,10 @@ #ifdef USE_SERIAL_PROXY +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include #include #include "esphome/core/util.h" @@ -12,10 +14,17 @@ #include "esphome/components/api/api_server.h" #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +#include "esphome/components/usb_uart/usb_uart.h" +#endif + namespace esphome::serial_proxy { static const char *const TAG = "serial_proxy"; +uint32_t SerialProxy::stall_loop_time = 0; +uint32_t SerialProxy::stall_spent_ms = 0; + void SerialProxy::setup() { // Set up modem control pins if configured if (this->rts_pin_ != nullptr) { @@ -30,6 +39,12 @@ void SerialProxy::setup() { // 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_USB_IDENTITY + if (this->usb_channel_ != nullptr) { + this->usb_channel_->get_parent()->add_on_connection_callback( + [this](bool connected) { this->on_usb_connection_changed_(connected); }); + } +#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 @@ -156,9 +171,10 @@ void SerialProxy::dump_config() { " RTS Pin: %s\n" " DTR Pin: %s", this->instance_index_, this->name_ != nullptr ? this->name_ : "", - this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485") - : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232") - : LOG_STR_LITERAL("TTL"), + this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485") + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232") + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL ? LOG_STR_LITERAL("USB_SERIAL") + : LOG_STR_LITERAL("TTL"), this->rts_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"), this->dtr_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured")); } @@ -293,6 +309,35 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui #endif if (data == nullptr || len == 0) return; + // Whatever the driver cannot buffer stalls the main loop for its wire time. At high baud + // rates that is brief and losing nothing is worth it; at low ones it would trip the + // watchdog, so cap the stall and drop the rest. The cap covers the whole loop pass: + // several writes can arrive in one, and each alone might stay under it. + const size_t free = this->parent_->available_for_write(); + bool trimmed = false; + if (len > free) { + const uint32_t loop_time = App.get_loop_component_start_time(); + if (loop_time != stall_loop_time) { + stall_loop_time = loop_time; + stall_spent_ms = 0; + } + const uint32_t stall_ms = this->wire_time_ms_(len - free); + trimmed = stall_spent_ms + stall_ms > SERIAL_PROXY_MAX_WRITE_STALL_MS; + if (trimmed && !this->trim_warned_) { + ESP_LOGW(TAG, + "TX buffer full on serial proxy [%" PRIu32 "]: dropping %zu of %zu bytes (would stall %" PRIu32 + " ms at %" PRIu32 " baud); raise the UART tx_buffer_size or pace writes", + this->instance_index_, len - free, len, stall_ms, this->parent_->get_baud_rate()); + } + if (trimmed) { + len = free; + } else { + stall_spent_ms += stall_ms; + } + } + this->trim_warned_ = trimmed; + if (len == 0) + return; this->write_array(data, len); #ifdef USE_SERIAL_PROXY_TAP @@ -303,6 +348,12 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui #endif } +uint32_t SerialProxy::wire_time_ms_(size_t bytes) const { + const uint32_t bits_per_byte = 1 + this->parent_->get_data_bits() + this->parent_->get_stop_bits() + + (this->parent_->get_parity() != uart::UART_CONFIG_PARITY_NONE ? 1 : 0); + return static_cast(bytes) * bits_per_byte * 1000 / std::max(this->parent_->get_baud_rate(), 1); +} + SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { #ifdef USE_API if (!this->is_subscriber_(api_connection)) { @@ -333,6 +384,60 @@ SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } +#ifdef USE_API +void SerialProxy::send_identity(api::APIConnection *api_connection) { + IdentityScratch scratch; + api::SerialProxyIdentity msg{}; + this->fill_identity_(scratch, msg); + api_connection->send_serial_proxy_identity(msg); +} + +void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api::SerialProxyIdentity &msg) const { + msg.instance = this->instance_index_; +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + // The define is global, so a hardware UART port in the same config also gets here + if (this->usb_channel_ != nullptr) { + msg.source = api::enums::SERIAL_PROXY_IDENTITY_SOURCE_USB; + // Covers a removed device, a channel the device has no CDC function for and a failed channel setup + if (!this->usb_channel_->is_connected()) { + return; + } + auto *client = this->usb_channel_->get_parent(); + msg.flags = api::enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED; + if (!client->get_device_info(scratch)) { + msg.flags |= api::enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR; + return; + } + msg.usb.vendor_id = scratch.vendor_id; + msg.usb.product_id = scratch.product_id; + msg.usb.bcd_device = scratch.bcd_device; + msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.manufacturer = StringRef(scratch.manufacturer); + msg.product = StringRef(scratch.product); + msg.serial_number = StringRef(scratch.serial_number); + return; + } +#endif + // Zero-initialized message: source NONE, no flags +} +#endif + +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +void SerialProxy::on_usb_connection_changed_(bool connected) { + ESP_LOGD(TAG, "USB device %s serial proxy [%" PRIu32 "]", + connected ? LOG_STR_LITERAL("attached to") : LOG_STR_LITERAL("removed from"), this->instance_index_); +#ifdef USE_API + if (api::global_api_server == nullptr) { + return; + } + IdentityScratch scratch; + api::SerialProxyIdentity msg{}; + this->fill_identity_(scratch, msg); + api::global_api_server->send_serial_proxy_identity(msg); +#endif +} +#endif + uint32_t SerialProxy::get_modem_pins() const { return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index e3f4264cfa5..1a406bc0891 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -20,6 +20,15 @@ #include "esphome/components/api/api_pb2.h" #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +namespace esphome::usb_uart { +class USBUartChannel; +} // namespace esphome::usb_uart +namespace esphome::usb_host { +struct UsbDeviceInfo; +} // namespace esphome::usb_host +#endif + // Forward-declare types needed outside the USE_API guard. namespace esphome::api { class APIConnection; @@ -53,6 +62,11 @@ 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; +/// Longest main-loop stall client writes may cause per loop pass, shared by every instance; +/// bytes the UART cannot buffer within it are dropped. Well under the shortest watchdog +/// timeout, since the API hands the proxies up to ten writes in one pass. +inline constexpr uint32_t SERIAL_PROXY_MAX_WRITE_STALL_MS = 1000; + #ifdef USE_SERIAL_PROXY_TAP /// Observes a port's traffic without owning it, and may inject bytes of its own. /// @@ -155,6 +169,16 @@ 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_USB_IDENTITY + /// Attach the USB UART channel behind this port (from code generation) + void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; } +#endif + +#ifdef USE_API + /// Send this port's identity to one client + void send_identity(api::APIConnection *api_connection); +#endif + #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; } @@ -204,6 +228,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } #endif + /// Time the wire needs for the given number of bytes at the current framing + uint32_t wire_time_ms_(size_t bytes) const; + #ifdef USE_SERIAL_PROXY_TAP /// Return the port to RAW when a subscriber goes away, so the mode never outlives it void reset_mode_(); @@ -218,9 +245,32 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool tap_observing_() const; #endif +#ifdef USE_API +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + using IdentityScratch = usb_host::UsbDeviceInfo; +#else + struct IdentityScratch {}; +#endif + /// Fill an identity message for this port. The message's strings are views into scratch, + /// so it must outlive the send. + void fill_identity_(IdentityScratch &scratch, api::SerialProxyIdentity &msg) const; +#endif + +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// The USB device behind this port was attached or removed; report the port's new + /// identity to every subscribed API client + void on_usb_connection_changed_(bool connected); +#endif + /// Instance index for identifying this proxy in API messages uint32_t instance_index_{0}; + /// Stall spent by writes in the current loop pass, keyed by the pass's cached start time. + /// Static on purpose: there is one main loop, and writes to different ports that arrive in + /// the same pass all stall it, so the budget is one per device rather than one per port + static uint32_t stall_loop_time; + static uint32_t stall_spent_ms; + /// Subscribed API client (only one allowed at a time) api::APIConnection *api_connection_{nullptr}; @@ -248,9 +298,17 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool rts_state_{false}; bool dtr_state_{false}; + /// Set while writes are being trimmed, so a client streaming into a slow port warns once + bool trim_warned_{false}; + #ifdef USE_SERIAL_PROXY_TAP SerialProxyTap *tap_{nullptr}; #endif + +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// The USB UART channel behind this port; nullptr on non-USB ports + usb_uart::USBUartChannel *usb_channel_{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/sht3xd/sensor.py b/esphome/components/sht3xd/sensor.py index eadca150500..baee146a22d 100644 --- a/esphome/components/sht3xd/sensor.py +++ b/esphome/components/sht3xd/sensor.py @@ -56,10 +56,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_heater_enabled(config[CONF_HEATER_ENABLED])) - 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_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/components/shtcx/sensor.py b/esphome/components/shtcx/sensor.py index bda48c07cea..d6ca2b131bd 100644 --- a/esphome/components/shtcx/sensor.py +++ b/esphome/components/shtcx/sensor.py @@ -51,10 +51,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if temperature := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature) - cg.add(var.set_temperature_sensor(sens)) - - if humidity := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity) - cg.add(var.set_humidity_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) 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/binary_sensor.py b/esphome/components/sim800l/binary_sensor.py index 1cf0b4e1b85..c2bfa94fd5f 100644 --- a/esphome/components/sim800l/binary_sensor.py +++ b/esphome/components/sim800l/binary_sensor.py @@ -19,8 +19,7 @@ CONFIG_SCHEMA = { async def to_code(config): - sim800l_component = await cg.get_variable(config[CONF_SIM800L_ID]) + hub = await cg.get_variable(config[CONF_SIM800L_ID]) - if CONF_REGISTERED in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_REGISTERED]) - cg.add(sim800l_component.set_registered_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_REGISTERED, hub.set_registered_binary_sensor) diff --git a/esphome/components/sim800l/sensor.py b/esphome/components/sim800l/sensor.py index 010e325df44..cc2ebb93a79 100644 --- a/esphome/components/sim800l/sensor.py +++ b/esphome/components/sim800l/sensor.py @@ -27,8 +27,7 @@ CONFIG_SCHEMA = { async def to_code(config): - sim800l_component = await cg.get_variable(config[CONF_SIM800L_ID]) + hub = await cg.get_variable(config[CONF_SIM800L_ID]) - if CONF_RSSI in config: - sens = await sensor.new_sensor(config[CONF_RSSI]) - cg.add(sim800l_component.set_rssi_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RSSI, hub.set_rssi_sensor) 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/__init__.py b/esphome/components/socket/__init__.py index edebed5d406..3d2cd8ef50d 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -1,18 +1,23 @@ from collections.abc import Callable, MutableMapping from dataclasses import dataclass from enum import StrEnum +from ipaddress import IPv4Address, IPv4Network import logging import esphome.codegen as cg +from esphome.components.const import CONF_ROLE from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] +socket_ns = cg.esphome_ns.namespace("socket") +Ipv4AllowEntry = socket_ns.struct("Ipv4AllowEntry") + CONF_IMPLEMENTATION = "implementation" IMPLEMENTATION_LWIP_TCP = "lwip_tcp" IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets" @@ -140,6 +145,81 @@ def require_wake_loop_threadsafe() -> None: cg.add_define("USE_SOCKET_SELECT_SUPPORT") +# For an Ipv4Allow config option; a sanity cap on the list length. +IPV4_ALLOW_SCHEMA = cv.All(cv.ensure_list(cv.ipv4network), cv.Length(max=255)) + + +_HOST = cv.Any(cv.domain, cv.hostname) + + +def ipv4_host(value: object) -> str: + """Validate an IPv4 address or a hostname; the resolver behind it is IPv4 only.""" + value = cv.string(value) + try: + cv.ipv6address(value) + except cv.Invalid: + return _HOST(value) + raise cv.Invalid( + "IPv6 addresses are not supported, use an IPv4 address or a hostname" + ) + + +def _network_order(addr: IPv4Address) -> int: + """The s_addr value for addr on the little endian targets.""" + return int.from_bytes(addr.packed, "little") + + +def add_ipv4_allow( + setter: cg.MockObj, networks: list[IPv4Network], owner_id: ID | str +) -> None: + """Emit a flash array for validated IPV4_ALLOW_SCHEMA entries and wire it to setter. + + PROGMEM on esp8266. Emits nothing for an empty list. + """ + if not networks: + return + cg.add_define("USE_SOCKET_IPV4_ALLOW") + entries = [ + cg.StructInitializer( + Ipv4AllowEntry, + ("addr", _network_order(net.network_address)), + ("mask", _network_order(net.netmask)), + ) + for net in networks + ] + arr_id = ID(f"{owner_id}_ipv4_allow", is_declaration=True, type=Ipv4AllowEntry) + arr = cg.progmem_array(arr_id, cg.ArrayInitializer(*entries)) + cg.add(setter(arr, len(entries))) + + +def require_ipv4_resolve() -> None: + """Compile the shared IPv4 lookup; call from a consumer's to_code.""" + cg.add_define("USE_SOCKET_IPV4_RESOLVE") + + +def require_tcp_client_link() -> None: + """Compile the reconnecting TCP client link; call from a consumer's to_code.""" + require_ipv4_resolve() + cg.add_define("USE_SOCKET_TCP_CLIENT_LINK") + + +def require_tcp_listener() -> None: + """Compile the TCP listener; call from a server role's to_code.""" + require_tcp_client_link() + cg.add_define("USE_SOCKET_TCP_LISTENER") + + +def consume_role_sockets(component: str) -> Callable[[ConfigType], ConfigType]: + """Socket accounting for a role keyed client or server schema.""" + + def validator(config: ConfigType) -> ConfigType: + if config[CONF_ROLE] == "server": + consume_sockets(1, component, SocketType.TCP_LISTEN)(config) + return consume_sockets(1, component)(config) + + return validator + + CONFIG_SCHEMA = cv.Schema( { cv.SplitDefault( @@ -184,11 +264,15 @@ async def to_code(config: ConfigType) -> None: # Each implementation file is fully #ifdef'd on the define set in to_code -# for the selected implementation. +# for the selected implementation. The helper files compile only for +# consumers that called the matching require_ function. FILTER_SOURCE_FILES = filter_source_files_from_defines( { "lwip_raw_tcp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "bsd_sockets_impl.cpp": "USE_SOCKET_IMPL_BSD_SOCKETS", "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", + "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", + "tcp_client_link.cpp": "USE_SOCKET_TCP_CLIENT_LINK", + "tcp_listener.cpp": "USE_SOCKET_TCP_LISTENER", } ) 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/ipv4_allow.h b/esphome/components/socket/ipv4_allow.h new file mode 100644 index 00000000000..f2f66edc05f --- /dev/null +++ b/esphome/components/socket/ipv4_allow.h @@ -0,0 +1,63 @@ +#pragma once + +#include "headers.h" +#include "socket.h" +#include "esphome/core/hal.h" + +#include +#include + +namespace esphome::socket { + +/// One allowed IPv4 network, network byte order, host bits cleared. +/// Lives in flash; read via progmem_memcpy. +struct Ipv4AllowEntry { + uint32_t addr; + uint32_t mask; +}; + +/// IPv4 peers that may connect. An empty list allows every peer. +class Ipv4Allow { + public: + void set(const Ipv4AllowEntry *entries, size_t count) { + this->entries_ = entries; + this->count_ = count; + } + + /// A v4 mapped IPv6 peer is unwrapped; any other family fails a non empty list. + bool allows(const struct sockaddr *peer) const { + if (this->count_ == 0) { + return true; + } + uint32_t addr; + return sockaddr_to_ipv4(peer, &addr) && this->allows(addr); + } + + /// addr is network byte order, as it sits in a sockaddr_in. + bool allows(uint32_t addr) const { + if (this->count_ == 0) { + return true; + } + for (size_t i = 0; i != this->count_; i++) { + Ipv4AllowEntry e = this->entry(i); + if ((addr & e.mask) == e.addr) { + return true; + } + } + return false; + } + + size_t size() const { return this->count_; } + /// A copy of entry i, read from flash. + Ipv4AllowEntry entry(size_t i) const { + Ipv4AllowEntry e; + progmem_memcpy(&e, &this->entries_[i], sizeof(e)); + return e; + } + + private: + const Ipv4AllowEntry *entries_{nullptr}; + size_t count_{0}; +}; + +} // namespace esphome::socket diff --git a/esphome/components/socket/ipv4_resolve.cpp b/esphome/components/socket/ipv4_resolve.cpp new file mode 100644 index 00000000000..d2316cd064b --- /dev/null +++ b/esphome/components/socket/ipv4_resolve.cpp @@ -0,0 +1,205 @@ +#include "ipv4_resolve.h" + +#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) + +#include "socket.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +#if !defined(USE_HOST) && !defined(USE_ZEPHYR) +#include "lwip/dns.h" +#else +#include +#endif + +namespace esphome::socket { + +static const char *const TAG = "socket"; + +bool Ipv4Resolve::consume_failure() { +#if defined(IPV4_RESOLVE_ATOMIC_STATE) + uint8_t expected = STATE_FAILED; + return this->state_word_.compare_exchange_strong(expected, STATE_IDLE); +#else + if (this->state_word_ != STATE_FAILED) { + return false; + } + this->state_word_ = STATE_IDLE; + return true; +#endif +} + +void Ipv4Resolve::forget() { +#if defined(IPV4_RESOLVE_VOLATILE_EPOCH) + // The generation moves first; RESOLVING stays set until the callback + // drops the stale result, so start() cannot replace that lookup. + this->epoch_ = this->epoch_ + 1; + this->addr_word_ = 0; + if (this->state_word_ != STATE_RESOLVING) { + this->state_word_ = STATE_IDLE; + } +#elif defined(IPV4_RESOLVE_ATOMIC_STATE) + // PUBLISHING stays, so start() cannot queue a second callback + // while this one is storing the address. + uint8_t expected = STATE_RESOLVING; + if (this->state_word_.compare_exchange_strong(expected, STATE_IDLE)) { + this->set_addr_(0); + return; + } + if (this->state_() == STATE_PUBLISHING) { + return; + } + this->set_state_(STATE_IDLE); + this->set_addr_(0); +#else + this->set_state_(STATE_IDLE); + this->set_addr_(0); +#endif +} + +socklen_t Ipv4Resolve::to_sockaddr(struct sockaddr *dest, socklen_t destlen, uint16_t port) const { + if (this->state_() != STATE_RESOLVED || destlen < sizeof(sockaddr_in)) { + return 0; + } + auto *in = reinterpret_cast(dest); + memset(in, 0, sizeof(sockaddr_in)); + in->sin_family = AF_INET; + in->sin_port = htons(port); + in->sin_addr.s_addr = this->addr_(); + return sizeof(sockaddr_in); +} + +#if !defined(USE_HOST) && !defined(USE_ZEPHYR) +#if defined(IPV4_RESOLVE_VOLATILE_EPOCH) +bool Ipv4Resolve::drop_stale_(uint32_t expected) { + if (this->epoch_ == expected) { + return false; + } + this->state_word_ = STATE_IDLE; + this->addr_word_ = 0; + return true; +} +#endif + +void Ipv4Resolve::dns_found(const char *name, const ip_addr_t *addr, void *arg) { + auto *self = static_cast(arg); +#if defined(IPV4_RESOLVE_VOLATILE_EPOCH) + const uint32_t expected = self->pending_epoch_; + if (self->drop_stale_(expected)) { + return; + } +#endif + if (addr != nullptr && IP_IS_V4(addr)) { +#if defined(IPV4_RESOLVE_ATOMIC_STATE) + // Only the callback that wins the exchange owns the lookup. + uint8_t expected = STATE_RESOLVING; + if (!self->state_word_.compare_exchange_strong(expected, STATE_PUBLISHING)) { + return; + } + self->set_addr_(ip4_addr_get_u32(ip_2_ip4(addr))); + expected = STATE_PUBLISHING; + // Lost ownership; the leftover address is gated by to_sockaddr()'s state check. + if (!self->state_word_.compare_exchange_strong(expected, STATE_RESOLVED)) { + return; + } +#elif defined(IPV4_RESOLVE_VOLATILE_EPOCH) + self->addr_word_ = ip4_addr_get_u32(ip_2_ip4(addr)); + self->state_word_ = STATE_RESOLVED; + if (self->drop_stale_(expected)) { + return; + } +#else + // Do not publish over a newer start(). + if (self->state_() != STATE_RESOLVING) { + return; + } + self->set_addr_(ip4_addr_get_u32(ip_2_ip4(addr))); + self->set_state_(STATE_RESOLVED); +#endif + } else { + ESP_LOGW(self->tag_ != nullptr ? self->tag_ : TAG, "DNS failed for %s", name); +#if defined(IPV4_RESOLVE_ATOMIC_STATE) + uint8_t expected = STATE_RESOLVING; + self->state_word_.compare_exchange_strong(expected, STATE_FAILED); +#elif defined(IPV4_RESOLVE_VOLATILE_EPOCH) + self->state_word_ = STATE_FAILED; + if (self->drop_stale_(expected)) { + return; + } +#else + if (self->state_() != STATE_RESOLVING) { + return; + } + self->set_state_(STATE_FAILED); +#endif + } +} +#endif + +void Ipv4Resolve::start(const char *host, uint16_t port, const char *tag) { + const uint8_t state = this->state_(); + if (state == STATE_RESOLVED || state == STATE_RESOLVING || state == STATE_PUBLISHING) { + return; + } + this->set_state_(STATE_IDLE); + this->tag_ = tag; + struct sockaddr_storage literal; + if (set_sockaddr(reinterpret_cast(&literal), sizeof(literal), host, port) != 0) { + if (literal.ss_family == AF_INET) { + auto *in = reinterpret_cast(&literal); + this->set_addr_(in->sin_addr.s_addr); + this->set_state_(STATE_RESOLVED); + return; + } + this->set_state_(STATE_FAILED); + ESP_LOGW(tag, "Not an IPv4 address: %s", host); + return; + } +#if !defined(USE_HOST) && !defined(USE_ZEPHYR) + ip_addr_t cached; + err_t err; + { + LwIPLock lock; +#if defined(IPV4_RESOLVE_VOLATILE_EPOCH) + this->pending_epoch_ = this->epoch_; +#endif + this->set_state_(STATE_RESOLVING); + err = dns_gethostbyname_addrtype(host, &cached, &Ipv4Resolve::dns_found, this, LWIP_DNS_ADDRTYPE_IPV4); + if (err != ERR_INPROGRESS && this->state_() == STATE_RESOLVING) { + this->set_state_(STATE_IDLE); + } + } + if (err == ERR_OK && IP_IS_V4(&cached)) { + this->set_addr_(ip4_addr_get_u32(ip_2_ip4(&cached))); + this->set_state_(STATE_RESOLVED); + return; + } + if (err == ERR_INPROGRESS || this->state_() == STATE_RESOLVED || this->state_() == STATE_PUBLISHING) { + return; + } +#else + struct addrinfo hints {}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo *res = nullptr; + if (getaddrinfo(host, nullptr, &hints, &res) == 0 && res != nullptr) { + auto *in = reinterpret_cast(res->ai_addr); + if (res->ai_family == AF_INET) { + this->set_addr_(in->sin_addr.s_addr); + this->set_state_(STATE_RESOLVED); + } + freeaddrinfo(res); + if (this->ready()) { + return; + } + } +#endif + this->set_state_(STATE_FAILED); + ESP_LOGW(tag, "Could not resolve %s", host); +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/ipv4_resolve.h b/esphome/components/socket/ipv4_resolve.h new file mode 100644 index 00000000000..628efb32d9c --- /dev/null +++ b/esphome/components/socket/ipv4_resolve.h @@ -0,0 +1,103 @@ +#pragma once + +#include "headers.h" + +#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) + +#include + +// MULTI_ATOMICS: one atomic state word, races closed by compare_exchange. +// SINGLE: volatile state, the callback never runs beside loop(). +// MULTI_NO_ATOMICS: volatile state plus a generation; BK72xx has no +// compare_exchange and the DNS callback runs on the tcpip thread. +#if defined(ESPHOME_THREAD_MULTI_NO_ATOMICS) +#define IPV4_RESOLVE_VOLATILE_EPOCH +#elif defined(ESPHOME_THREAD_SINGLE) +#define IPV4_RESOLVE_VOLATILE +#else +#define IPV4_RESOLVE_ATOMIC_STATE +#include +#endif + +#if !defined(USE_HOST) && !defined(USE_ZEPHYR) +#include "lwip/ip_addr.h" +#endif + +namespace esphome::socket { + +/// One IPv4 literal or hostname. Must outlive a pending lookup. +class Ipv4Resolve { + public: + static constexpr uint8_t STATE_IDLE = 0; + static constexpr uint8_t STATE_RESOLVING = 1; + static constexpr uint8_t STATE_RESOLVED = 2; + static constexpr uint8_t STATE_FAILED = 3; + // The callback holds this between winning the lookup and storing the address. + static constexpr uint8_t STATE_PUBLISHING = 4; + + /// Drop the stored address so the next start() resolves again. + /// A result already publishing may still land, so ready() can be true + /// right after this; after changing hosts, forget() until ready() is false. + void forget(); + /// Drop a failed lookup so the next start() tries again. + bool consume_failure(); + bool ready() const { return this->state_() == STATE_RESOLVED; } + /// Write the stored address into dest. Returns 0 until ready() is true. + socklen_t to_sockaddr(struct sockaddr *dest, socklen_t destlen, uint16_t port) const; + /// Resolve host; tag names the failure log. On host and Zephyr this + /// blocks in getaddrinfo(). + void start(const char *host, uint16_t port, const char *tag); + + private: +#if !defined(USE_HOST) && !defined(USE_ZEPHYR) + static void dns_found(const char *name, const ip_addr_t *addr, void *arg); +#if defined(IPV4_RESOLVE_VOLATILE_EPOCH) + bool drop_stale_(uint32_t expected); +#endif +#endif + uint8_t state_() const { +#if defined(IPV4_RESOLVE_ATOMIC_STATE) + return this->state_word_.load(); +#else + return this->state_word_; +#endif + } + uint32_t addr_() const { +#if defined(IPV4_RESOLVE_ATOMIC_STATE) + return this->addr_word_.load(); +#else + return this->addr_word_; +#endif + } + void set_state_(uint8_t state) { +#if defined(IPV4_RESOLVE_ATOMIC_STATE) + this->state_word_.store(state); +#else + this->state_word_ = state; +#endif + } + void set_addr_(uint32_t addr) { +#if defined(IPV4_RESOLVE_ATOMIC_STATE) + this->addr_word_.store(addr); +#else + this->addr_word_ = addr; +#endif + } + const char *tag_{nullptr}; +#if defined(IPV4_RESOLVE_ATOMIC_STATE) + std::atomic addr_word_{0}; + std::atomic state_word_{STATE_IDLE}; +#elif defined(IPV4_RESOLVE_VOLATILE_EPOCH) + volatile uint32_t addr_word_{0}; + volatile uint32_t epoch_{0}; + volatile uint32_t pending_epoch_{0}; + volatile uint8_t state_word_{STATE_IDLE}; +#else + volatile uint32_t addr_word_{0}; + volatile uint8_t state_word_{STATE_IDLE}; +#endif +}; + +} // 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..f6d1daa1240 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" @@ -75,6 +78,25 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t #endif #endif +bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out) { + if (addr->sa_family == AF_INET) { + *out = reinterpret_cast(addr)->sin_addr.s_addr; + return true; + } +#if USE_NETWORK_IPV6 + if (addr->sa_family == AF_INET6) { + // ::ffff:a.b.c.d; s6_addr is the portable byte view on every stack. + static constexpr uint8_t V4_MAPPED_PREFIX[12] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF}; + const uint8_t *bytes = reinterpret_cast(addr)->sin6_addr.s6_addr; + if (memcmp(bytes, V4_MAPPED_PREFIX, sizeof(V4_MAPPED_PREFIX)) == 0) { + memcpy(out, bytes + sizeof(V4_MAPPED_PREFIX), sizeof(*out)); + return true; + } + } +#endif + return false; +} + // 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) { if (addr_ptr->sa_family == AF_INET && len >= sizeof(const struct sockaddr_in)) { @@ -85,29 +107,10 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s #if USE_NETWORK_IPV6 else if (addr_ptr->sa_family == AF_INET6 && len >= sizeof(sockaddr_in6)) { const auto *addr = reinterpret_cast(addr_ptr); -#ifdef USE_HOST - // Format IPv4-mapped IPv6 addresses as regular IPv4 (POSIX layout, no LWIP union) - if (IN6_IS_ADDR_V4MAPPED(&addr->sin6_addr) && - esphome_inet_ntop4(&addr->sin6_addr.s6_addr[12], buf.data(), buf.size()) != nullptr) { + uint32_t v4; + // Format a v4 mapped peer as plain IPv4. + if (sockaddr_to_ipv4(addr_ptr, &v4) && esphome_inet_ntop4(&v4, buf.data(), buf.size()) != nullptr) return strlen(buf.data()); - } -#elif defined(USE_ZEPHYR) - // Format IPv4-mapped IPv6 addresses as regular IPv4. Zephyr uses the standard POSIX - // s6_addr layout (not the LWIP union) but provides no IN6_IS_ADDR_V4MAPPED macro, so - // detect the ::ffff:0:0/96 prefix directly on the address words. - if (addr->sin6_addr.s6_addr32[0] == 0 && addr->sin6_addr.s6_addr32[1] == 0 && - addr->sin6_addr.s6_addr32[2] == htonl(0xFFFF) && - esphome_inet_ntop4(&addr->sin6_addr.s6_addr32[3], buf.data(), buf.size()) != nullptr) { - return strlen(buf.data()); - } -#elif !defined(USE_SOCKET_IMPL_LWIP_TCP) - // Format IPv4-mapped IPv6 addresses as regular IPv4 (LWIP layout) - if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 && - addr->sin6_addr.un.u32_addr[2] == htonl(0xFFFF) && - esphome_inet_ntop4(&addr->sin6_addr.un.u32_addr[3], buf.data(), buf.size()) != nullptr) { - return strlen(buf.data()); - } -#endif if (esphome_inet_ntop6(&addr->sin6_addr, buf.data(), buf.size()) != nullptr) return strlen(buf.data()); } @@ -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..9d7dedbbbf9 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -137,6 +137,10 @@ inline std::unique_ptr socket_ip_loop_monitored(int type, int prot /// @return Size of the sockaddr structure used, or 0 on error socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port); +/// Network order IPv4 of an AF_INET or v4 mapped AF_INET6 peer; false for anything else. +/// addr must hold the family's full struct. +bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out); + /// Convenience overload for std::string (backward compatible). inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::string &ip_address, uint16_t port) { return set_sockaddr(addr, addrlen, ip_address.c_str(), port); @@ -145,6 +149,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/socket/tcp_client_link.cpp b/esphome/components/socket/tcp_client_link.cpp new file mode 100644 index 00000000000..9812f1835e1 --- /dev/null +++ b/esphome/components/socket/tcp_client_link.cpp @@ -0,0 +1,176 @@ +#include "tcp_client_link.h" + +#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::socket { + +// After this long in SYN, the stack's own retries are cut short. +static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; + +// Non-blocking options and TCP keepalive for a bridged stream socket. +// Keepalive is best-effort: the raw lwIP implementation (ESP8266, RP2040) +// rejects it, so a half-open link there is only detected by a failed write. +static void set_stream_options(Socket *sock, const char *tag) { + int yes = 1; + // Fails only on an invalid descriptor, or on raw lwIP after a peer reset that the next read() reports. + sock->setblocking(false); + int err = sock->setsockopt(IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)); + err |= sock->setsockopt(SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes)); +#ifdef TCP_KEEPIDLE + int idle = 30; + int interval = 10; + int count = 3; + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval)); + err |= sock->setsockopt(IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count)); +#endif + if (err != 0) { + ESP_LOGV(tag, "Nodelay/keepalive not fully applied"); + } +} + +void TcpClientLink::begin(const char *tag) { + this->tag_ = tag; + // The first attempt must not wait out a full interval. + this->last_attempt_ms_ = App.get_loop_component_start_time() - this->reconnect_interval_ms_; +} + +void TcpClientLink::poll_slow_() { + if (this->sock_ == nullptr) { + this->try_connect_(); + return; + } + int err = 0; + switch (poll_connect(*this->sock_, err)) { + case ConnectPollResult::CONNECT_POLL_RESULT_PENDING: + // Give up before the stack's SYN retries do, so the interval stays honest + // and the next attempt resolves the host again. + if (App.get_loop_component_start_time() - this->last_attempt_ms_ >= + std::max(this->reconnect_interval_ms_, CONNECT_TIMEOUT_MS)) { + this->drop_(LOG_STR("Connect failed"), ETIMEDOUT); + } + return; + case ConnectPollResult::CONNECT_POLL_RESULT_ERROR: + this->drop_(LOG_STR("Connect failed"), err); + return; + default: + break; + } + this->connected_ = true; + ESP_LOGI(this->tag_, "Connected to %s:%u", this->host_.c_str(), this->port_); +} + +void TcpClientLink::try_connect_() { + if (this->resolved_.consume_failure()) { + this->note_attempt(); + return; + } + this->resolved_.start(this->host_.c_str(), this->port_, this->tag_); + if (!this->resolved_.ready()) { + return; + } + struct sockaddr_storage dest; + socklen_t dest_len = + this->resolved_.to_sockaddr(reinterpret_cast(&dest), sizeof(dest), this->port_); + if (dest_len == 0) { + this->note_attempt(); + return; + } + this->sock_ = socket_loop_monitored(dest.ss_family, SOCK_STREAM, IPPROTO_TCP); + if (this->sock_ == nullptr) { + this->drop_(LOG_STR("Connect failed"), errno); + return; + } + set_stream_options(this->sock_.get(), this->tag_); + // Starts the pending-connect clock that poll() times out against. + this->note_attempt(); + // An immediate success is reported by the next poll(); poll_connect() sees it writable. + if (this->sock_->connect(reinterpret_cast(&dest), dest_len) != 0 && errno != EINPROGRESS) { + this->drop_(LOG_STR("Connect failed"), errno); + } +} + +void TcpClientLink::adopt(std::unique_ptr sock) { + this->close(); + set_stream_options(sock.get(), this->tag_); + this->sock_ = std::move(sock); + this->connected_ = true; +} + +ssize_t TcpClientLink::read(uint8_t *buf, size_t len) { + if (!this->connected_) { + return 0; + } + ssize_t count = this->sock_->read(buf, len); + if (count > 0) { + return count; + } + if (count == 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) { + this->drop_(LOG_STR("Connection lost"), count == 0 ? 0 : errno); + return -1; + } + return 0; +} + +ssize_t TcpClientLink::write_(const uint8_t *buf, size_t len) { + if (!this->connected_ || len == 0) { + return 0; + } + ssize_t sent = this->sock_->write(buf, len); + if (sent >= 0) { + return sent; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return 0; + } + this->drop_(LOG_STR("Connection lost"), errno); + return -1; +} + +size_t TcpClientLink::queue(const uint8_t *data, size_t len) { + size_t room = this->tx_free(); + if (len > room) { + len = room; + } + std::memcpy(this->tx_ + this->tx_len_, data, len); + this->tx_len_ += static_cast(len); + return len; +} + +void TcpClientLink::flush_tx_slow_() { + ssize_t sent = this->write_(this->tx_, this->tx_len_); + if (sent > 0) { + this->tx_len_ -= static_cast(sent); + if (this->tx_len_ != 0) { + std::memmove(this->tx_, this->tx_ + sent, this->tx_len_); + } + } +} + +void TcpClientLink::close() { + if (this->sock_ != nullptr) { + this->sock_->shutdown(SHUT_RDWR); + this->sock_->close(); + this->sock_.reset(); + } + this->connected_ = false; + this->tx_len_ = 0; + this->resolved_.forget(); +} + +void TcpClientLink::drop_(const LogString *what, int err) { + ESP_LOGW(this->tag_, "%s: %d", LOG_STR_ARG(what), err); + this->close(); + this->note_attempt(); +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/tcp_client_link.h b/esphome/components/socket/tcp_client_link.h new file mode 100644 index 00000000000..f77ee050555 --- /dev/null +++ b/esphome/components/socket/tcp_client_link.h @@ -0,0 +1,96 @@ +#pragma once + +#include "headers.h" + +#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) + +#include "ipv4_resolve.h" +#include "socket.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" +#include "esphome/core/string_ref.h" + +#include +#include + +namespace esphome::socket { + +/// A reconnecting TCP stream driven from loop(). Owns the socket, the DNS +/// lookup, the retry backoff and the outgoing buffer. A fatal read/write +/// error closes the link and schedules the next attempt; the caller sees +/// the edge via connected(). +class TcpClientLink { + public: + void set_host(const char *host) { this->host_ = StringRef(host); } + void set_port(uint16_t port) { this->port_ = port; } + void set_reconnect_interval(uint32_t ms) { this->reconnect_interval_ms_ = ms; } + const char *host() const { return this->host_.c_str(); } + uint16_t port() const { return this->port_; } + uint32_t reconnect_interval() const { return this->reconnect_interval_ms_; } + + /// Call from setup(). tag names this link's log lines. + void begin(const char *tag); + /// Connect state machine; call every loop while acting as a client. + /// Inline no-op while connected or waiting out the backoff. + void poll() { + if (this->connected_ || (this->sock_ == nullptr && this->in_backoff())) { + return; + } + this->poll_slow_(); + } + /// Take over an accepted socket (the server side of a bridge). + void adopt(std::unique_ptr sock); + /// Returns bytes moved, 0 when nothing can move now, -1 when the link dropped. + ssize_t read(uint8_t *buf, size_t len); + /// Copy into the outgoing buffer; returns how many bytes fit. + size_t queue(const uint8_t *data, size_t len); + /// Direct access to the buffer's free tail. Fill at most tx_free() bytes, + /// then tx_commit() the count; neither is bounds checked. + uint8_t *tx_tail() { return this->tx_ + this->tx_len_; } + void tx_commit(size_t len) { this->tx_len_ += static_cast(len); } + size_t tx_free() const { return this->connected_ ? TX_BUFFER_SIZE - this->tx_len_ : 0; } + /// Send the front of the buffer; true once it is empty. + /// A partial write keeps the rest; inline no-op while nothing is queued. + bool flush_tx() { + if (this->tx_len_ != 0) { + this->flush_tx_slow_(); + } + return this->tx_len_ == 0; + } + /// Close without scheduling a reconnect (shutdown). + void close(); + + bool connected() const { return this->connected_; } + bool ready() const { return this->sock_ != nullptr && this->sock_->ready(); } + /// Shared retry clock, also usable for a listen socket. + void note_attempt() { this->last_attempt_ms_ = App.get_loop_component_start_time(); } + bool in_backoff() const { + return App.get_loop_component_start_time() - this->last_attempt_ms_ < this->reconnect_interval_ms_; + } + + protected: + static constexpr size_t TX_BUFFER_SIZE = 1024; + + /// The raw stream write behind flush_tx(); drops the link on a fatal error. + ssize_t write_(const uint8_t *buf, size_t len); + void flush_tx_slow_(); + void poll_slow_(); + void try_connect_(); + /// Close after a failure, log what and errno, schedule the next attempt. + void drop_(const LogString *what, int err); + + StringRef host_; + std::unique_ptr sock_; + const char *tag_{nullptr}; + uint32_t last_attempt_ms_{0}; + uint32_t reconnect_interval_ms_{5000}; + Ipv4Resolve resolved_; + uint16_t port_{0}; + uint16_t tx_len_{0}; + bool connected_{false}; + uint8_t tx_[TX_BUFFER_SIZE]{}; +}; + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/tcp_listener.cpp b/esphome/components/socket/tcp_listener.cpp new file mode 100644 index 00000000000..e1f00e29277 --- /dev/null +++ b/esphome/components/socket/tcp_listener.cpp @@ -0,0 +1,90 @@ +#include "tcp_listener.h" + +#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::socket { + +// One client at a time; a second connection waits in the stack until the first drops. +static constexpr int LISTEN_BACKLOG = 1; +#ifdef USE_SOCKET_IPV4_ALLOW +static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; +#endif + +void TcpListener::try_listen_(TcpClientLink &link) { + this->listen_ = socket_ip_loop_monitored(SOCK_STREAM, IPPROTO_TCP); + int err = errno; + if (this->listen_ != nullptr) { + int yes = 1; + this->listen_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + struct sockaddr_storage local; + socklen_t local_len = set_sockaddr_any(reinterpret_cast(&local), sizeof(local), link.port()); + // A blocking listener would stall loop() inside accept(), so its + // setblocking result is part of the success condition. + if (this->listen_->setblocking(false) == 0 && local_len != 0 && + this->listen_->bind(reinterpret_cast(&local), local_len) == 0 && + this->listen_->listen(LISTEN_BACKLOG) == 0) { + ESP_LOGI(this->tag_, "Listening on %u", link.port()); + return; + } + // Captured before reset(); the close inside can overwrite errno. + err = errno; + this->listen_.reset(); + } + ESP_LOGW(this->tag_, "Listen on %u failed: %d", link.port(), err); + link.note_attempt(); +} + +void TcpListener::accept_(TcpClientLink &link) { + struct sockaddr_storage peer {}; + socklen_t peer_len = sizeof(peer); + auto client = this->listen_->accept_loop_monitored(reinterpret_cast(&peer), &peer_len); + if (client == nullptr) { + // A reset during the handshake or a signal only affects that connection. + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == ECONNABORTED || errno == EINTR) { + return; + } + // Rebuild the listener after the backoff instead of spinning on it. + int err = errno; + this->listen_.reset(); + ESP_LOGW(this->tag_, "Accept failed: %d", err); + link.note_attempt(); + return; + } + const auto *sa = reinterpret_cast(&peer); + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(sa, peer_len, std::span(text)); +#ifdef USE_SOCKET_IPV4_ALLOW + if (!this->allow_.allows(sa)) { + uint32_t now = App.get_loop_component_start_time(); + if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { + this->last_reject_log_ms_ = now; + ESP_LOGW(this->tag_, "Rejected %s", text); + } + return; + } +#endif + link.adopt(std::move(client)); + ESP_LOGI(this->tag_, "Client connected from %s", text); +} + +void TcpListener::dump_config() const { +#ifdef USE_SOCKET_IPV4_ALLOW + for (size_t i = 0; i < this->allow_.size(); i++) { + Ipv4AllowEntry e = this->allow_.entry(i); + // Network order is dotted order, and the contiguous mask's popcount is the prefix. + const auto *b = reinterpret_cast(&e.addr); + ESP_LOGCONFIG(this->tag_, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], + static_cast(__builtin_popcount(e.mask))); + } +#endif +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/tcp_listener.h b/esphome/components/socket/tcp_listener.h new file mode 100644 index 00000000000..a42080e641f --- /dev/null +++ b/esphome/components/socket/tcp_listener.h @@ -0,0 +1,60 @@ +#pragma once + +#include "headers.h" + +#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS) + +#ifdef USE_SOCKET_IPV4_ALLOW +#include "ipv4_allow.h" +#endif +#include "socket.h" +#include "tcp_client_link.h" + +#include +#include + +namespace esphome::socket { + +/// The server side of a bridged TCP link: owns the listen socket and the +/// allow list, accepts one peer at a time and adopts it into a TcpClientLink, +/// sharing that link's retry clock and connect port. +class TcpListener { + public: +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const Ipv4AllowEntry *entries, size_t count) { this->allow_.set(entries, count); } +#endif + + /// Call from setup(); tag names the log lines. + void begin(const char *tag) { this->tag_ = tag; } + /// Server state machine; call every loop. may_accept lets the caller hold + /// accepts until its own disconnect edge has run. + void poll(TcpClientLink &link, bool may_accept) { + if (this->listen_ == nullptr) { + if (!link.in_backoff()) { + this->try_listen_(link); + } + return; + } + if (may_accept && !link.connected() && this->listen_->ready()) { + this->accept_(link); + } + } + void close() { this->listen_.reset(); } + /// One config line per allowed network. + void dump_config() const; + + protected: + void try_listen_(TcpClientLink &link); + void accept_(TcpClientLink &link); + + std::unique_ptr listen_; + const char *tag_{nullptr}; +#ifdef USE_SOCKET_IPV4_ALLOW + uint32_t last_reject_log_ms_{0}; + Ipv4Allow allow_; +#endif +}; + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/sound_level/sensor.py b/esphome/components/sound_level/sensor.py index d2175340415..c8e31226efa 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( @@ -77,12 +73,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_measurement_duration(config[CONF_MEASUREMENT_DURATION])) - if peak_config := config.get(CONF_PEAK): - sens = await sensor.new_sensor(peak_config) - cg.add(var.set_peak_sensor(sens)) - if rms_config := config.get(CONF_RMS): - sens = await sensor.new_sensor(rms_config) - cg.add(var.set_rms_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PEAK, var.set_peak_sensor) + await sensors(CONF_RMS, var.set_rms_sensor) SOUND_LEVEL_ACTION_SCHEMA = automation.maybe_simple_id( @@ -92,18 +85,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/spa06_base/__init__.py b/esphome/components/spa06_base/__init__.py index c995c2c0879..e084020a141 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -181,9 +181,9 @@ CONFIG_SCHEMA_BASE.add_extra(measurement_timing_check) 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)) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + temperature_config = config[CONF_TEMPERATURE] cg.add( var.set_temperature_oversampling_config( temperature_config[CONF_OVERSAMPLING] @@ -193,9 +193,8 @@ async def to_code_base(config: ConfigType) -> MockObj: var.set_temperature_sample_rate_config(temperature_config[CONF_SAMPLE_RATE]) ) - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + pressure_config = config[CONF_PRESSURE] cg.add(var.set_pressure_oversampling_config(pressure_config[CONF_OVERSAMPLING])) cg.add(var.set_pressure_sample_rate_config(pressure_config[CONF_SAMPLE_RATE])) 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 e1808889f4b..159af158086 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -22,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, @@ -63,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) ) @@ -239,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), @@ -251,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("const").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 509984cfa29..f150a485735 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -66,7 +66,7 @@ void AudioPipeline::start_url(const std::string &uri) { this->pending_url_ = true; } -void AudioPipeline::start_file(audio::AudioFile *audio_file) { +void AudioPipeline::start_file(const audio::AudioFile *audio_file) { if (this->is_playing_) { xEventGroupSetBits(this->event_group_, PIPELINE_COMMAND_STOP); } diff --git a/esphome/components/speaker/media_player/audio_pipeline.h b/esphome/components/speaker/media_player/audio_pipeline.h index 02dad15de97..b114a7b7a51 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.h +++ b/esphome/components/speaker/media_player/audio_pipeline.h @@ -74,7 +74,7 @@ class AudioPipeline final { /// @brief Starts an audio pipeline given a AudioFile pointer /// @param audio_file pointer to an AudioFile object /// @return ESP_OK if successful or an appropriate error if not - void start_file(audio::AudioFile *audio_file); + void start_file(const audio::AudioFile *audio_file); /// @brief Stops the pipeline. Sends a stop signal to each task (if running) and clears the ring buffers. /// @return ESP_OK if successful or ESP_ERR_TIMEOUT if the tasks did not indicate they stopped @@ -121,7 +121,7 @@ class AudioPipeline final { speaker::Speaker *speaker_{nullptr}; std::string current_uri_{}; - audio::AudioFile *current_audio_file_{nullptr}; + const audio::AudioFile *current_audio_file_{nullptr}; audio::AudioFileType current_audio_file_type_; audio::AudioStreamInfo current_audio_stream_info_; 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 9ce50d7b762..ab98935bdad 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. @@ -222,7 +224,7 @@ void SpeakerMediaPlayer::watch_media_commands_() { break; case media_player::MEDIA_PLAYER_COMMAND_TURN_OFF: this->is_turn_off_ = true; - // Intentional Fall-through + [[fallthrough]]; #endif case media_player::MEDIA_PLAYER_COMMAND_STOP: // Pipelines do not stop immediately after calling the stop command, so confirm its stopped before unpausing. @@ -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; @@ -471,7 +493,7 @@ void SpeakerMediaPlayer::loop() { #endif } -void SpeakerMediaPlayer::play_file(audio::AudioFile *media_file, bool announcement, bool enqueue) { +void SpeakerMediaPlayer::play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue) { if (!this->is_ready()) { // Ignore any commands sent before the media player is setup return; diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 6470fb925c2..e98196785c8 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -28,13 +28,13 @@ struct MediaCallCommand { optional volume; optional announce; optional url; // Must be manually deleted after receiving this struct from a queue - optional file; + optional file; optional enqueue; }; struct PlaylistItem { optional url; - optional file; + optional file; }; struct VolumeRestoreState { @@ -87,7 +87,7 @@ class SpeakerMediaPlayer final : public Component, Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; } Trigger *get_volume_trigger() { return &this->volume_trigger_; } - void play_file(audio::AudioFile *media_file, bool announcement, bool enqueue); + void play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue); void set_playlist_delay_ms(AudioPipelineType pipeline_type, uint32_t delay_ms); @@ -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 01e9ca042e0..277b36a171a 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -127,9 +127,9 @@ class Speaker { #endif State state_{STATE_STOPPED}; + bool mute_state_{false}; audio::AudioStreamInfo audio_stream_info_; float volume_{1.0f}; - bool mute_state_{false}; #ifdef USE_AUDIO_DAC audio_dac::AudioDac *audio_dac_{nullptr}; 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 215f3942d58..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; } 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/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/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/stcc4/__init__.py b/esphome/components/stcc4/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py new file mode 100644 index 00000000000..212fe6bb489 --- /dev/null +++ b/esphome/components/stcc4/sensor.py @@ -0,0 +1,141 @@ +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_AMBIENT_PRESSURE_COMPENSATION, + CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, + CONF_CO2, + CONF_HUMIDITY, + CONF_ID, + CONF_MEASUREMENT_MODE, + CONF_TEMPERATURE, + CONF_TEMPERATURE_SOURCE, + CONF_UPDATE_INTERVAL, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + ICON_MOLECULE_CO2, + ICON_THERMOMETER, + ICON_WATER_PERCENT, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@j9brown"] +DEPENDENCIES = ["i2c"] +AUTO_LOAD = ["sensirion_common"] + +stcc4_ns = cg.esphome_ns.namespace("stcc4") +STCC4Component = stcc4_ns.class_( + "STCC4Component", cg.PollingComponent, sensirion_common.SensirionI2CDevice +) + +MeasurementMode = stcc4_ns.enum("MeasurementMode", is_class=True) + +MEASUREMENT_MODE_OPTIONS = { + "continuous": MeasurementMode.CONTINUOUS, + "single_shot": MeasurementMode.SINGLE_SHOT, +} + + +def validate_config(config: ConfigType) -> ConfigType: + if config[CONF_MEASUREMENT_MODE] == "continuous": + if CONF_UPDATE_INTERVAL in config: + raise cv.Invalid( + "update_interval must not be specified in continuous measurement mode" + ) + elif CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = cv.update_interval("60s") + return config + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(STCC4Component), + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + 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, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + icon=ICON_WATER_PERCENT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Inclusive(CONF_TEMPERATURE_SOURCE, "rht_compensation"): cv.use_id( + sensor.Sensor + ), + cv.Inclusive(CONF_HUMIDITY_SOURCE, "rht_compensation"): cv.use_id( + sensor.Sensor + ), + cv.Exclusive( + CONF_AMBIENT_PRESSURE_COMPENSATION, "ambient_pressure_compensation" + ): cv.All(cv.pressure, cv.float_range(min=0.4, max=1.1)), + cv.Exclusive( + CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE, + "ambient_pressure_compensation", + ): cv.use_id(sensor.Sensor), + cv.Optional(CONF_MEASUREMENT_MODE, default="continuous"): cv.enum( + MEASUREMENT_MODE_OPTIONS, lower=True + ), + cv.Optional(CONF_UPDATE_INTERVAL): cv.update_interval, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x64)) + .add_extra(validate_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) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + + if (temperature_source := config.get(CONF_TEMPERATURE_SOURCE)) is not None: + sens = await cg.get_variable(temperature_source) + cg.add(var.set_temperature_source(sens)) + + if (humidity_source := config.get(CONF_HUMIDITY_SOURCE)) is not None: + sens = await cg.get_variable(humidity_source) + cg.add(var.set_humidity_source(sens)) + + if ( + ambient_pressure_compensation := config.get(CONF_AMBIENT_PRESSURE_COMPENSATION) + ) is not None: + cg.add( + var.set_ambient_pressure_compensation( + ambient_pressure_compensation * 1000 # convert bar to hPa + ) + ) + + if ( + ambient_pressure_compensation_source := config.get( + CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE + ) + ) is not None: + sens = await cg.get_variable(ambient_pressure_compensation_source) + cg.add(var.set_ambient_pressure_source(sens)) + + cg.add(var.set_measurement_mode(config[CONF_MEASUREMENT_MODE])) diff --git a/esphome/components/stcc4/stcc4.cpp b/esphome/components/stcc4/stcc4.cpp new file mode 100644 index 00000000000..efc7a2b8ee7 --- /dev/null +++ b/esphome/components/stcc4/stcc4.cpp @@ -0,0 +1,294 @@ +#include + +#include "stcc4.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::stcc4 { + +static const char *const TAG = "stcc4"; + +// I2C Commands +static constexpr uint16_t STCC4_CMD_START_CONTINUOUS_MEASUREMENT = 0x218b; +static constexpr uint16_t STCC4_CMD_STOP_CONTINUOUS_MEASUREMENT = 0x3f86; +static constexpr uint16_t STCC4_CMD_MEASURE_SINGLE_SHOT = 0x219d; +static constexpr uint16_t STCC4_CMD_READ_MEASUREMENT = 0xec05; +static constexpr uint16_t STCC4_CMD_GET_PRODUCT_ID = 0x365b; +static constexpr uint16_t STCC4_CMD_SET_RHT_COMPENSATION = 0xe000; +static constexpr uint16_t STCC4_CMD_SET_PRESSURE_COMPENSATION = 0xe016; + +// Exit sleep is an 8-bit command (single byte 0x00), not 16-bit +static constexpr uint8_t STCC4_CMD_EXIT_SLEEP_MODE = 0x00; + +static constexpr uint32_t STCC4_PRODUCT_ID = 0x0901018a; + +// Timeout for determining when the device is ready for use, in milliseconds. +// While waiting for the previous measurement to finish, the device will NACK all I2C requests +// and it can take up to 1200 ms for the operation to complete according to the datasheet. +static constexpr uint32_t READY_TIMEOUT_MS = 1200; + +// Poll interval for determining when the device is ready for use, in milliseconds. +static constexpr uint32_t READY_POLL_INTERVAL_MS = 100; + +// Convert units the the device's representation according to the datasheet. +constexpr uint16_t temperature_in_c_to_ticks(float temperature_in_c) { + return uint16_t((std::clamp(temperature_in_c, -45.f, 130.f) + 45.f) * 65535.f / 175.f); +} + +constexpr float temperature_in_ticks_to_c(uint16_t temperature_in_ticks) { + return temperature_in_ticks * 175.f / 65535.f - 45.f; +} + +constexpr uint16_t humidity_in_percent_to_ticks(float humidity_in_percent) { + return uint16_t((std::clamp(humidity_in_percent, 0.f, 100.f) + 6.f) * 65535.f / 125.f); +} + +constexpr float humidity_in_ticks_to_percent(uint16_t humidity_in_ticks) { + return humidity_in_ticks * 125.f / 65535.f - 6.f; +} + +constexpr uint16_t pressure_in_hpa_to_pa_2(float pressure_in_hpa) { + return uint16_t(std::clamp(pressure_in_hpa, 400.f, 1100.f) * 50.f); +} + +constexpr float pressure_in_pa_2_to_hpa(uint16_t pressure_in_pa_2) { return pressure_in_pa_2 / 50.f; } + +void STCC4Component::setup() { + this->stop_poller(); // not ready yet + + // Wait 100 ms after power up before attempting to communicate with the sensor + this->set_timeout(100, [this]() { + // Send exit sleep mode command (8-bit, NACK expected), wait 5 ms to exit sleep + this->write_command(STCC4_CMD_EXIT_SLEEP_MODE); + this->set_timeout(5, [this]() { this->poll_until_ready_for_setup_or_timeout_(millis()); }); + }); +} + +void STCC4Component::poll_until_ready_for_setup_or_timeout_(uint32_t start_time) { + // Stop continuous measurements in case they were previously running + // The device may NACK this request if it is not ready to communicate yet + if (this->write_command(STCC4_CMD_STOP_CONTINUOUS_MEASUREMENT)) { + // Read product ID to verify communication (6 words: 2 for product_id + 4 for serial) + // The device may NACK this request if it is not ready to communicate yet + uint16_t raw_product_id[6]; + if (this->get_register(STCC4_CMD_GET_PRODUCT_ID, raw_product_id, 6, 1)) { + uint32_t product_id = (uint32_t(raw_product_id[0]) << 16) | raw_product_id[1]; + uint64_t serial_number = (uint64_t(raw_product_id[2]) << 48) | (uint64_t(raw_product_id[3]) << 32) | + (uint64_t(raw_product_id[4]) << 16) | raw_product_id[5]; + ESP_LOGD(TAG, "Product ID: 0x%08" PRIX32 ", Serial: 0x%016" PRIX64, product_id, serial_number); + if (product_id != STCC4_PRODUCT_ID) { + ESP_LOGE(TAG, "Unsupported product ID"); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Set static ambient pressure compensation if configured + if (this->ambient_pressure_in_pa_2_ != 0) { + if (!this->write_ambient_pressure_compensation_(this->ambient_pressure_in_pa_2_)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + } + + // Apply the dynamic compensation sources' current values, if configured + if (this->temperature_source_ != nullptr && this->humidity_source_ != nullptr) { + this->update_rht_compensation_from_source_(); + } + if (this->ambient_pressure_source_ != nullptr) { + this->update_ambient_pressure_compensation_from_source_(); + } + + if (this->measurement_mode_ == MeasurementMode::SINGLE_SHOT) { + this->start_poller(); + this->finish_setup_(); + return; + } + + // Start continuous measurement + if (!this->write_command(STCC4_CMD_START_CONTINUOUS_MEASUREMENT)) { + ESP_LOGE(TAG, "Failed to start continuous measurement"); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->schedule_continuous_update_(false); + this->finish_setup_(); + return; + } + } + + if (millis() - start_time < READY_TIMEOUT_MS) { + ESP_LOGVV(TAG, "Retry sync"); + this->set_timeout(READY_POLL_INTERVAL_MS, + [this, start_time]() { this->poll_until_ready_for_setup_or_timeout_(start_time); }); + return; + } + + ESP_LOGE(TAG, "Failed to stop continuous measurements and read product ID"); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); +} + +void STCC4Component::finish_setup_() { + this->ready_ = true; + // Follow the sources only once measuring started, so a failed setup stops writing to the device + if (this->temperature_source_ != nullptr && this->humidity_source_ != nullptr) { + this->temperature_source_->add_on_state_callback([this](float) { this->update_rht_compensation_from_source_(); }); + this->humidity_source_->add_on_state_callback([this](float) { this->update_rht_compensation_from_source_(); }); + } + if (this->ambient_pressure_source_ != nullptr) { + this->ambient_pressure_source_->add_on_state_callback( + [this](float) { this->update_ambient_pressure_compensation_from_source_(); }); + } +} + +void STCC4Component::dump_config() { + ESP_LOGCONFIG(TAG, "STCC4:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGW(TAG, ESP_LOG_MSG_COMM_FAIL); + } + ESP_LOGCONFIG(TAG, " Measurement mode: %s", + this->measurement_mode_ == MeasurementMode::CONTINUOUS ? LOG_STR_LITERAL("Continuous (1s)") + : LOG_STR_LITERAL("Single shot")); + if (this->ambient_pressure_source_ != nullptr) { + ESP_LOGCONFIG(TAG, " Dynamic ambient pressure compensation using '%s'", + this->ambient_pressure_source_->get_name().c_str()); + } else if (this->ambient_pressure_in_pa_2_ != 0) { + ESP_LOGCONFIG(TAG, " Ambient pressure compensation: %f hPa", + pressure_in_pa_2_to_hpa(this->ambient_pressure_in_pa_2_)); + } + if (this->temperature_source_ != nullptr) { + ESP_LOGCONFIG(TAG, " Temperature compensation using '%s'", this->temperature_source_->get_name().c_str()); + } + if (this->humidity_source_ != nullptr) { + ESP_LOGCONFIG(TAG, " Humidity compensation using '%s'", this->humidity_source_->get_name().c_str()); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "CO2", this->co2_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); +} + +void STCC4Component::update() { + if (!this->ready_ || this->measurement_mode_ != MeasurementMode::SINGLE_SHOT) + return; + + // Perform single-shot measurement, wait 500 ms for the measurement to be ready + if (!this->write_command(STCC4_CMD_MEASURE_SINGLE_SHOT)) { + ESP_LOGW(TAG, "Failed to start single shot measurement"); + this->status_set_warning(); + return; + } + this->set_timeout(500, [this]() { + if (this->read_measurement_(0)) { + this->status_clear_warning(); + } else { + ESP_LOGW(TAG, "Failed to read measurement data"); + this->status_set_warning(); + } + }); +} + +void STCC4Component::schedule_continuous_update_(bool retry_for_clock_drift) { + // In continuous measurement mode, the STCC4 produces a sample every 1000 ms according to its + // internal clock. The datasheet recommends retrying 150 ms after a failed read to compensate + // for clock drift between the host and the device. + this->set_timeout(retry_for_clock_drift ? 150 : 1000, [this, retry_for_clock_drift]() { + if (this->read_measurement_(retry_for_clock_drift ? 0 : sensirion_common::SENSIRION_OPTION_READ_MAY_NACK)) { + this->status_clear_warning(); + this->schedule_continuous_update_(false); + } else if (!retry_for_clock_drift) { + this->schedule_continuous_update_(true); + } else { + this->status_set_warning(); + this->schedule_continuous_update_(false); + } + }); +} + +bool STCC4Component::read_measurement_(uint8_t sensirion_options) { + // Read measurement data: 4 words (CO2, temperature, humidity, status) + uint16_t raw_data[4]; + if (!this->get_register_(STCC4_CMD_READ_MEASUREMENT, ADDR_16_BIT, raw_data, 4, 1, sensirion_options)) { + return false; + } + + // CO2 value is in ppm as int16 (ignore negative values during warm-up) + const int16_t co2_raw = int16_t(raw_data[0]); + if (this->co2_sensor_ != nullptr && co2_raw >= 0) { + this->co2_sensor_->publish_state(co2_raw); + } + + if (this->temperature_sensor_ != nullptr) { + this->temperature_sensor_->publish_state(temperature_in_ticks_to_c(raw_data[1])); + } + + if (this->humidity_sensor_ != nullptr) { + this->humidity_sensor_->publish_state(humidity_in_ticks_to_percent(raw_data[2])); + } + return true; +} + +void STCC4Component::update_rht_compensation_from_source_() { + const float temperature_in_c = this->temperature_source_->state; + const float humidity_in_percent = this->humidity_source_->state; + if (std::isnan(temperature_in_c) || std::isnan(humidity_in_percent)) + return; + + const uint16_t temperature_in_ticks = temperature_in_c_to_ticks(temperature_in_c); + const uint16_t humidity_in_ticks = humidity_in_percent_to_ticks(humidity_in_percent); + if ((this->temperature_in_ticks_ != temperature_in_ticks || this->humidity_in_ticks_ != humidity_in_ticks) && + this->write_rht_compensation_(temperature_in_ticks, humidity_in_ticks)) { + this->temperature_in_ticks_ = temperature_in_ticks; + this->humidity_in_ticks_ = humidity_in_ticks; + } +} + +void STCC4Component::set_ambient_pressure_compensation(float pressure_in_hpa) { + this->ambient_pressure_in_pa_2_ = pressure_in_hpa_to_pa_2(pressure_in_hpa); +} + +void STCC4Component::update_ambient_pressure_compensation_from_source_() { + const float pressure_in_hpa = this->ambient_pressure_source_->state; + if (std::isnan(pressure_in_hpa)) + return; + + if (pressure_in_hpa < 100.f || pressure_in_hpa > 10000.f) { + // Some pressure sensors report values in Pa instead of hPa and there's no way to check at compile time. + // Warn if the value seems far outside of the expected range. + if (!this->ambient_pressure_unit_warning_logged_) { + this->ambient_pressure_unit_warning_logged_ = true; + ESP_LOGW(TAG, "Ambient pressure compensation sensor might have incompatible units: got %f hPa", pressure_in_hpa); + } + return; // skip this update + } else { + this->ambient_pressure_unit_warning_logged_ = false; + } + + const uint16_t ambient_pressure_in_pa_2 = pressure_in_hpa_to_pa_2(pressure_in_hpa); + if (this->ambient_pressure_in_pa_2_ != ambient_pressure_in_pa_2 && + this->write_ambient_pressure_compensation_(ambient_pressure_in_pa_2)) { + this->ambient_pressure_in_pa_2_ = ambient_pressure_in_pa_2; + } +} + +bool STCC4Component::write_rht_compensation_(uint16_t temperature_in_ticks, uint16_t humidity_in_ticks) { + ESP_LOGVV(TAG, "Set RHT compensation: %f °C, %f %%RH", temperature_in_ticks_to_c(temperature_in_ticks), + humidity_in_ticks_to_percent(humidity_in_ticks)); + const uint16_t data[2] = {temperature_in_ticks, humidity_in_ticks}; + if (!this->write_command(STCC4_CMD_SET_RHT_COMPENSATION, data, 2)) { + ESP_LOGE(TAG, "Failed to set RHT compensation"); + return false; + } + return true; +} + +bool STCC4Component::write_ambient_pressure_compensation_(uint16_t pressure_in_pa_2) { + ESP_LOGVV(TAG, "Set pressure compensation: %f hPa", pressure_in_pa_2_to_hpa(pressure_in_pa_2)); + if (!this->write_command(STCC4_CMD_SET_PRESSURE_COMPENSATION, pressure_in_pa_2)) { + ESP_LOGE(TAG, "Failed to set ambient pressure compensation"); + return false; + } + return true; +} + +} // namespace esphome::stcc4 diff --git a/esphome/components/stcc4/stcc4.h b/esphome/components/stcc4/stcc4.h new file mode 100644 index 00000000000..ad25130ec73 --- /dev/null +++ b/esphome/components/stcc4/stcc4.h @@ -0,0 +1,55 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/sensirion_common/i2c_sensirion.h" + +namespace esphome::stcc4 { + +enum class MeasurementMode : uint8_t { + CONTINUOUS = 0, + SINGLE_SHOT, +}; + +class STCC4Component final : public PollingComponent, public sensirion_common::SensirionI2CDevice { + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_co2_sensor(sensor::Sensor *co2) { this->co2_sensor_ = co2; } + void set_temperature_sensor(sensor::Sensor *temperature) { this->temperature_sensor_ = temperature; } + void set_humidity_sensor(sensor::Sensor *humidity) { this->humidity_sensor_ = humidity; } + void set_temperature_source(sensor::Sensor *temperature) { this->temperature_source_ = temperature; } + void set_humidity_source(sensor::Sensor *humidity) { this->humidity_source_ = humidity; } + void set_ambient_pressure_compensation(float pressure_in_hpa); + void set_ambient_pressure_source(sensor::Sensor *pressure) { this->ambient_pressure_source_ = pressure; } + void set_measurement_mode(MeasurementMode mode) { this->measurement_mode_ = mode; } + + protected: + void poll_until_ready_for_setup_or_timeout_(uint32_t start_time); + void finish_setup_(); + void schedule_continuous_update_(bool retry_for_clock_drift); + bool read_measurement_(uint8_t sensirion_options); + void update_rht_compensation_from_source_(); + void update_ambient_pressure_compensation_from_source_(); + bool write_rht_compensation_(uint16_t temperature_in_ticks, uint16_t humidity_in_ticks); + bool write_ambient_pressure_compensation_(uint16_t pressure_in_pa_2); + + sensor::Sensor *co2_sensor_{nullptr}; + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + sensor::Sensor *temperature_source_{nullptr}; + sensor::Sensor *humidity_source_{nullptr}; + sensor::Sensor *ambient_pressure_source_{nullptr}; + + MeasurementMode measurement_mode_{MeasurementMode::CONTINUOUS}; + + uint16_t temperature_in_ticks_{0}; + uint16_t humidity_in_ticks_{0}; + uint16_t ambient_pressure_in_pa_2_{0}; + bool ambient_pressure_unit_warning_logged_{false}; + bool ready_{false}; +}; + +} // namespace esphome::stcc4 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/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 5ef7a699ebb..d59f39b9c22 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -353,7 +353,7 @@ def resolve_include( strict_undefined: bool = True, errors: ErrList | None = None, ) -> Any: - """Resolve an include, substituting the filename if needed. + """Resolve an include, substituting the condition and filename if needed. Note: no path-traversal validation is performed on the resolved filename. A substitution that resolves to an absolute path will bypass the parent @@ -362,6 +362,19 @@ def resolve_include( values (including command-line substitutions), so path restrictions are an explicit non-goal here. """ + if isinstance(original_condition := include.condition, str): + condition = str( + _expand_substitutions( + original_condition, + path + ["condition"], + context_vars, + strict_undefined, + errors, + ) + ) + if condition != original_condition: + include = include.with_condition(condition) + original = include.file filename = str( _expand_substitutions( @@ -372,8 +385,8 @@ def resolve_include( if substituted: include = include.with_file(filename) try: - return include.load() - except esphome.core.EsphomeError as err: + return include.load() if include.should_load() else {} + except (esphome.core.EsphomeError, cv.Invalid) as err: resolved = f" (expanded from '{original}')" if substituted else "" raise cv.Invalid( f"Error including file '{filename}'{resolved}: {err}" 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 57e4f222bce..2cc8ad782d9 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -57,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..63c3711b10d 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]: @@ -161,6 +146,11 @@ def validate_raw_data(value: Any) -> bytes | list[int]: ) +MAX_PACKET_SIZE = 255 +# The radio sends packets of 1 to MAX_PACKET_SIZE bytes. +validate_packet_data = cv.All(validate_raw_data, cv.Length(min=1, max=MAX_PACKET_SIZE)) + + def validate_config(config: ConfigType) -> ConfigType: lora_bws = [ "7_8kHz", @@ -220,7 +210,9 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), cv.Optional(CONF_PA_POWER, default=17): cv.int_range(min=-3, max=22), cv.Optional(CONF_PA_RAMP, default="40us"): cv.enum(RAMP), - cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=255), + cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range( + min=0, max=MAX_PACKET_SIZE + ), cv.Optional(CONF_PREAMBLE_DETECT, default=2): cv.int_range(min=0, max=4), cv.Optional(CONF_PREAMBLE_SIZE, default=8): cv.int_range(min=1, max=65535), cv.Required(CONF_RST_PIN): pins.gpio_output_pin_schema, @@ -302,40 +294,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,30 +311,17 @@ 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( { cv.GenerateID(): cv.use_id(SX126x), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_packet_data), }, key=CONF_DATA, ) @@ -387,15 +341,11 @@ async def send_packet_action_to_code( ) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], + args, + var.set_data_template, + var.set_data_static, + "sx126x_data", + ) return var diff --git a/esphome/components/sx126x/automation.h b/esphome/components/sx126x/automation.h index 4eb33abaa1e..0dbf3cb42c4 100644 --- a/esphome/components/sx126x/automation.h +++ b/esphome/components/sx126x/automation.h @@ -6,62 +6,13 @@ 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...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - std::vector data; - if (this->len_ >= 0) { - // Static mode: copy from flash to vector - data.assign(this->data_.data, this->data_.data + this->len_); - } else { - // Template mode: call function - data = this->data_.func(x...); - } - this->parent_->transmit_packet(data); + this->data_.template visit( + [this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } 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/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 376676ce850..673464d5eb7 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -42,13 +42,13 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector &packet) { return status; } -void SX126x::write_fifo_(uint8_t offset, const std::vector &packet) { +void SX126x::write_fifo_(uint8_t offset, const uint8_t *data, size_t len) { this->enable(); this->wait_busy_(); this->transfer_byte(RADIO_WRITE_BUFFER); this->transfer_byte(offset); - for (const uint8_t &byte : packet) { - this->transfer_byte(byte); + for (size_t i = 0; i < len; i++) { + this->transfer_byte(data[i]); } this->disable(); delayMicroseconds(SWITCHING_DELAY_US); @@ -280,7 +280,7 @@ size_t SX126x::get_max_packet_size() { if (this->payload_length_ > 0) { return this->payload_length_; } - return 255; + return SX126X_MAX_PACKET_SIZE; } void SX126x::set_packet_params_(uint8_t payload_length) { @@ -312,12 +312,12 @@ void SX126x::set_packet_params_(uint8_t payload_length) { } } -SX126xError SX126x::transmit_packet(const std::vector &packet) { - if (this->payload_length_ > 0 && this->payload_length_ != packet.size()) { +SX126xError SX126x::transmit_packet(const uint8_t *data, size_t len) { + if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX126xError::INVALID_PARAMS; } - if (packet.empty() || packet.size() > this->get_max_packet_size()) { + if (len == 0 || len > this->get_max_packet_size()) { ESP_LOGE(TAG, "Packet size out of range"); return SX126xError::INVALID_PARAMS; } @@ -325,9 +325,9 @@ SX126xError SX126x::transmit_packet(const std::vector &packet) { SX126xError ret = SX126xError::NONE; this->set_mode_standby(STDBY_XOSC); if (this->payload_length_ == 0) { - this->set_packet_params_(packet.size()); + this->set_packet_params_(len); } - this->write_fifo_(0x00, packet); + this->write_fifo_(0x00, data, len); this->set_mode_tx(); // wait until transmit completes, typically the delay will be less than 100 ms diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index b3dfe6590a3..536bb93c48f 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -10,6 +10,8 @@ namespace esphome::sx126x { +static constexpr size_t SX126X_MAX_PACKET_SIZE = 255; + enum SX126xBw : uint8_t { // FSK SX126X_BW_4800, @@ -97,7 +99,10 @@ class SX126x final : public Component, void set_tcxo_delay(uint32_t tcxo_delay) { this->tcxo_delay_ = tcxo_delay; } void run_image_cal(); void configure(); - SX126xError transmit_packet(const std::vector &packet); + SX126xError transmit_packet(const uint8_t *data, size_t len); + SX126xError transmit_packet(const std::vector &packet) { + return this->transmit_packet(packet.data(), packet.size()); + } void register_listener(SX126xListener *listener) { this->listeners_.push_back(listener); } Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } @@ -107,7 +112,7 @@ class SX126x final : public Component, void configure_lora_(); void set_packet_params_(uint8_t payload_length); uint8_t read_fifo_(uint8_t offset, std::vector &packet); - void write_fifo_(uint8_t offset, const std::vector &packet); + void write_fifo_(uint8_t offset, const uint8_t *data, size_t len); void write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size); uint8_t read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size); void write_register_(uint16_t reg, uint8_t *data, uint8_t size); diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 34f2d4122f6..68c56cdff49 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]: @@ -150,6 +135,11 @@ def validate_raw_data(value: Any) -> bytes | list[int]: ) +MAX_PACKET_SIZE = 255 +# The radio's payload length register is 8 bits, and empty packets are rejected. +validate_packet_data = cv.All(validate_raw_data, cv.Length(min=1, max=MAX_PACKET_SIZE)) + + def validate_config(config: ConfigType) -> ConfigType: if config[CONF_MODULATION] == "LORA": bws = [ @@ -213,7 +203,9 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_PA_POWER, default=17): cv.int_range(min=0, max=17), cv.Optional(CONF_PA_RAMP, default="40us"): cv.enum(RAMP), cv.Optional(CONF_PACKET_MODE): cv.boolean, - cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range(min=0, max=256), + cv.Optional(CONF_PAYLOAD_LENGTH, default=0): cv.int_range( + min=0, max=MAX_PACKET_SIZE + ), cv.Optional(CONF_PREAMBLE_DETECT, default=0): cv.int_range(min=0, max=3), cv.Optional(CONF_PREAMBLE_ERRORS, default=0): cv.int_range(min=0, max=31), cv.Optional(CONF_PREAMBLE_POLARITY, default=0xAA): cv.All( @@ -286,51 +278,22 @@ 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( { cv.GenerateID(): cv.use_id(SX127x), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_packet_data), }, key=CONF_DATA, ) @@ -350,15 +313,11 @@ async def send_packet_action_to_code( ) -> MockObj: var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], + args, + var.set_data_template, + var.set_data_static, + "sx127x_data", + ) return var diff --git a/esphome/components/sx127x/automation.h b/esphome/components/sx127x/automation.h index f6a4537e231..8d409f0b012 100644 --- a/esphome/components/sx127x/automation.h +++ b/esphome/components/sx127x/automation.h @@ -6,61 +6,14 @@ 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 { + TEMPLATABLE_BYTES(data) + public: - void set_data_template(std::vector (*func)(Ts...)) { - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } - void play(const Ts &...x) override { - std::vector data; - if (this->len_ >= 0) { - // Static mode: copy from flash to vector - data.assign(this->data_.data, this->data_.data + this->len_); - } else { - // Template mode: call function - data = this->data_.func(x...); - } - this->parent_->transmit_packet(data); + this->data_.template visit( + [this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Data { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } 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/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index cd81f089149..9af30400a7c 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -43,11 +43,11 @@ void SX127x::read_fifo_(std::vector &packet) { this->disable(); } -void SX127x::write_fifo_(const std::vector &packet) { +void SX127x::write_fifo_(const uint8_t *data, size_t len) { this->enable(); this->write_byte(REG_FIFO | 0x80); - for (const auto &byte : packet) { - this->transfer_byte(byte); + for (size_t i = 0; i < len; i++) { + this->transfer_byte(data[i]); } this->disable(); } @@ -250,18 +250,17 @@ size_t SX127x::get_max_packet_size() { return this->payload_length_; } if (this->modulation_ == MOD_LORA) { - return 256; - } else { - return 64; + return SX127X_MAX_PACKET_SIZE; } + return 64; } -SX127xError SX127x::transmit_packet(const std::vector &packet) { - if (this->payload_length_ > 0 && this->payload_length_ != packet.size()) { +SX127xError SX127x::transmit_packet(const uint8_t *data, size_t len) { + if (this->payload_length_ > 0 && this->payload_length_ != len) { ESP_LOGE(TAG, "Packet size does not match config"); return SX127xError::INVALID_PARAMS; } - if (packet.empty() || packet.size() > this->get_max_packet_size()) { + if (len == 0 || len > this->get_max_packet_size()) { ESP_LOGE(TAG, "Packet size out of range"); return SX127xError::INVALID_PARAMS; } @@ -275,18 +274,18 @@ SX127xError SX127x::transmit_packet(const std::vector &packet) { if (this->modulation_ == MOD_LORA) { this->set_mode_standby(); if (this->payload_length_ == 0) { - this->write_register_(REG_PAYLOAD_LENGTH, packet.size()); + this->write_register_(REG_PAYLOAD_LENGTH, len); } this->write_register_(REG_IRQ_FLAGS, 0xFF); this->write_register_(REG_FIFO_ADDR_PTR, 0); - this->write_fifo_(packet); + this->write_fifo_(data, len); this->set_mode_tx(); } else { this->set_mode_standby(); if (this->payload_length_ == 0) { - this->write_register_(REG_FIFO, packet.size()); + this->write_register_(REG_FIFO, len); } - this->write_fifo_(packet); + this->write_fifo_(data, len); this->set_mode_tx(); } diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index 070a6eeb96a..27031d98b2b 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -9,6 +9,8 @@ namespace esphome::sx127x { +static constexpr size_t SX127X_MAX_PACKET_SIZE = 255; // the payload length register is 8 bits + enum SX127xBw : uint8_t { SX127X_BW_2_6, SX127X_BW_3_1, @@ -81,7 +83,10 @@ class SX127x final : public Component, void set_sync_value(const std::vector &sync_value) { this->sync_value_ = sync_value; } void run_image_cal(); void configure(); - SX127xError transmit_packet(const std::vector &packet); + SX127xError transmit_packet(const uint8_t *data, size_t len); + SX127xError transmit_packet(const std::vector &packet) { + return this->transmit_packet(packet.data(), packet.size()); + } void register_listener(SX127xListener *listener) { this->listeners_.push_back(listener); } Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } @@ -90,7 +95,7 @@ class SX127x final : public Component, void configure_fsk_ook_(); void configure_lora_(); void set_mode_(uint8_t modulation, uint8_t mode); - void write_fifo_(const std::vector &packet); + void write_fifo_(const uint8_t *data, size_t len); void read_fifo_(std::vector &packet); void write_register_(uint8_t reg, uint8_t value); void call_listeners_(const std::vector &packet, float rssi, float snr); diff --git a/esphome/components/sx1509/__init__.py b/esphome/components/sx1509/__init__.py index 7694b8f7326..942911c7fe8 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -43,6 +43,10 @@ SX1509KeyTrigger = sx1509_ns.class_( def check_keys(config: ConfigType) -> ConfigType: + for ch in config.get(CONF_KEYS, ""): + if not ch.isascii(): + # Each key is reported as one byte, so only ASCII characters can be key codes + raise cv.Invalid(f"Key code {ch!r} is not an ASCII character") if ( CONF_KEYS in config and len(config[CONF_KEYS]) != config[CONF_KEY_ROWS] * config[CONF_KEY_COLUMNS] @@ -98,8 +102,11 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_sleep_time(conf[CONF_SLEEP_TIME])) cg.add(var.set_scan_time(conf[CONF_SCAN_TIME])) cg.add(var.set_debounce_time(conf[CONF_DEBOUNCE_TIME])) - if keys := conf.get(CONF_KEYS): - cg.add(var.set_keys(keys)) + if (keys := conf.get(CONF_KEYS)) is not None: + table = cg.shared_progmem_array( + "sx1509_keys", cg.uint8, list(keys.encode()) + ) + cg.add(var.set_keys(table)) for tconf in conf.get(CONF_ON_KEY, []): trigger = cg.new_Pvariable(tconf[CONF_TRIGGER_ID]) cg.add(var.register_key_trigger(trigger)) @@ -126,7 +133,7 @@ CONF_SX1509 = "sx1509" SX1509_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(SX1509GPIOPin), - cv.Required(CONF_SX1509): cv.use_id(SX1509Component), + cv.Required(CONF_SX1509): pins.use_id_or_address(SX1509Component), cv.Required(CONF_NUMBER): cv.int_range(min=0, max=15), cv.Optional(CONF_MODE, default={}): cv.All( { diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 2397049000d..ee8606db96d 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -48,7 +48,7 @@ void SX1509Component::loop() { uint16_t key_data = this->read_key_data(); for (auto *binary_sensor : this->keypad_binary_sensors_) binary_sensor->process(key_data); - if (this->keys_.empty()) + if (this->keys_ == nullptr) return; if (key_data == 0) { this->last_key_ = 0; @@ -64,7 +64,9 @@ void SX1509Component::loop() { break; } col -= 8; - uint8_t key = this->keys_[row * this->cols_ + col]; + if (row >= this->rows_ || col >= this->cols_) // a partial read can leave only a row or a column bit + return; + uint8_t key = progmem_read_byte(&this->keys_[row * this->cols_ + col]); if (key == this->last_key_) return; this->last_key_ = key; diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index c7aed2cddd1..ef6ee6ec5e6 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -51,7 +51,8 @@ class SX1509Component final : public Component, this->cols_ = cols; this->has_keypad_ = true; }; - void set_keys(std::string keys) { this->keys_ = std::move(keys); }; // NOLINT(performance-unnecessary-value-param) + /// `keys` is a codegen PROGMEM table with one key code per button. + void set_keys(const uint8_t *keys) { this->keys_ = keys; } void set_sleep_time(uint16_t sleep_time) { this->sleep_time_ = sleep_time; }; void set_scan_time(uint8_t scan_time) { this->scan_time_ = scan_time; }; void set_debounce_time(uint8_t debounce_time = 1) { this->debounce_time_ = debounce_time; }; @@ -76,7 +77,7 @@ class SX1509Component final : public Component, bool has_keypad_ = false; uint8_t rows_ = 0; uint8_t cols_ = 0; - std::string keys_; + const uint8_t *keys_{nullptr}; uint16_t sleep_time_ = 128; uint8_t scan_time_ = 1; uint8_t debounce_time_ = 1; 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/t6615/sensor.py b/esphome/components/t6615/sensor.py index 44dba52ae82..4b055516e79 100644 --- a/esphome/components/t6615/sensor.py +++ b/esphome/components/t6615/sensor.py @@ -48,6 +48,5 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await uart.register_uart_device(var, config) - if co2 := config.get(CONF_CO2): - sens = await sensor.new_sensor(co2) - cg.add(var.set_co2_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_CO2, var.set_co2_sensor) 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/tas58xx/__init__.py b/esphome/components/tas58xx/__init__.py new file mode 100644 index 00000000000..0931d7ba1f3 --- /dev/null +++ b/esphome/components/tas58xx/__init__.py @@ -0,0 +1,10 @@ +from esphome.config_helpers import filter_source_files_from_defines + +CODEOWNERS = ["@mrtoy-me", "@remcom"] + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "model_tas5805m.cpp": "USE_TAS58XX_TAS5805M", + "model_tas5825m.cpp": "USE_TAS58XX_TAS5825M", + } +) diff --git a/esphome/components/tas58xx/audio_dac.py b/esphome/components/tas58xx/audio_dac.py new file mode 100644 index 00000000000..7b1e3e239e8 --- /dev/null +++ b/esphome/components/tas58xx/audio_dac.py @@ -0,0 +1,189 @@ +from dataclasses import dataclass +import logging + +from esphome import automation, pins +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_ENABLE_PIN, CONF_ID, CONF_MODEL +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ["i2c"] + +CONF_ANALOG_GAIN = "analog_gain" +CONF_DAC_MODE = "dac_mode" +CONF_IGNORE_ENABLE_PIN_WARNING = "ignore_enable_pin_warning" +CONF_MIXER_MODE = "mixer_mode" +CONF_VOLUME_MIN_DB = "volume_min_db" +CONF_VOLUME_MAX_DB = "volume_max_db" +CONF_TAS58XX_ID = "tas58xx_id" + +DAC_TAS5805M = "tas5805m" +DAC_TAS5825M = "tas5825m" + +tas58xx_ns = cg.esphome_ns.namespace("tas58xx") +TAS58xx = tas58xx_ns.class_("TAS58xx", AudioDac, cg.PollingComponent, i2c.I2CDevice) + +DacMode = tas58xx_ns.enum("DacMode") +DAC_MODES = { + "btl": DacMode.DAC_MODE_BTL, + "pbtl": DacMode.DAC_MODE_PBTL, +} + +MixerMode = tas58xx_ns.enum("MixerMode") +MIXER_MODES = { + "stereo": MixerMode.MIXER_MODE_STEREO, + "stereo_inverse": MixerMode.MIXER_MODE_STEREO_INVERSE, + "mono": MixerMode.MIXER_MODE_MONO, + "left": MixerMode.MIXER_MODE_LEFT, + "right": MixerMode.MIXER_MODE_RIGHT, +} + + +@dataclass(frozen=True) +class Model: + """Limits of one model. The C++ side of a model is its ModelInfo constant.""" + + model_info: MockObj + analog_gain_min_db: float + volume_min_db: float + volume_max_db: float + default_address: int + + +MODELS: dict[str, Model] = { + DAC_TAS5805M: Model( + model_info=tas58xx_ns.TAS5805M_MODEL, + analog_gain_min_db=-15.5, + volume_min_db=-103.0, + volume_max_db=24.0, + default_address=0x2D, + ), + DAC_TAS5825M: Model( + model_info=tas58xx_ns.TAS5825M_MODEL, + analog_gain_min_db=-15.5, + volume_min_db=-103.0, + volume_max_db=24.0, + default_address=0x4C, + ), +} + + +def _analog_gain_validator(model: Model): + range_validator = cv.All( + cv.decibel, cv.float_range(min=model.analog_gain_min_db, max=0.0) + ) + + def validator(value: float) -> float: + value = range_validator(value) + if value * 2 != int(value * 2): + raise cv.Invalid("analog_gain must be a multiple of 0.5 dB") + return value + + return validator + + +def _model_schema(model: Model) -> cv.Schema: + volume_validator = cv.All( + cv.decibel, + cv.float_range(min=model.volume_min_db, max=model.volume_max_db), + ) + return ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TAS58xx), + cv.Optional(CONF_ENABLE_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_IGNORE_ENABLE_PIN_WARNING, default=False): cv.boolean, + cv.Optional( + CONF_ANALOG_GAIN, default=model.analog_gain_min_db + ): _analog_gain_validator(model), + cv.Optional(CONF_DAC_MODE, default="btl"): cv.enum( + DAC_MODES, lower=True + ), + cv.Optional(CONF_MIXER_MODE, default="stereo"): cv.enum( + MIXER_MODES, lower=True + ), + cv.Optional( + CONF_VOLUME_MIN_DB, default=model.volume_min_db + ): volume_validator, + cv.Optional( + CONF_VOLUME_MAX_DB, default=model.volume_max_db + ): volume_validator, + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(model.default_address)) + ) + + +def _validate_config(config: ConfigType) -> ConfigType: + if config[CONF_VOLUME_MIN_DB] >= config[CONF_VOLUME_MAX_DB]: + raise cv.Invalid(f"{CONF_VOLUME_MIN_DB} must be less than {CONF_VOLUME_MAX_DB}") + if config[CONF_DAC_MODE] == "pbtl" and config[CONF_MIXER_MODE] in ( + "stereo", + "stereo_inverse", + ): + raise cv.Invalid( + f"{CONF_DAC_MODE} 'pbtl' drives a single speaker; use {CONF_MIXER_MODE} 'mono', 'left' or 'right'" + ) + if CONF_ENABLE_PIN in config: + if config[CONF_IGNORE_ENABLE_PIN_WARNING]: + raise cv.Invalid( + f"{CONF_IGNORE_ENABLE_PIN_WARNING} only applies when {CONF_ENABLE_PIN} is not set" + ) + elif not config[CONF_IGNORE_ENABLE_PIN_WARNING]: + # Without PDN high the device does not answer on I2C, and setup only reports an I2C failure + _LOGGER.warning( + "%s: %s not configured - if PDN (power down) is not hardwired high then add %s. " + "Set %s: true to hide this warning", + config[CONF_ID], + CONF_ENABLE_PIN, + CONF_ENABLE_PIN, + CONF_IGNORE_ENABLE_PIN_WARNING, + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + {name: _model_schema(model) for name, model in MODELS.items()}, + key=CONF_MODEL, + lower=True, + ), + _validate_config, +) + + +TAS58XX_ACTION_SCHEMA = maybe_simple_id({cv.GenerateID(): cv.use_id(TAS58xx)}) + +for _name, _call in ( + ("tas58xx.activate", "activate()"), + ("tas58xx.deactivate", "deactivate()"), +): + automation.register_apply_action( + _name, TAS58XX_ACTION_SCHEMA, automation.ApplyCall(_call) + ) + + +async def to_code(config: ConfigType) -> None: + model = MODELS[config[CONF_MODEL]] + cg.add_define(f"USE_TAS58XX_{config[CONF_MODEL].upper()}") + var = cg.new_Pvariable(config[CONF_ID], cg.RawExpression(f"&{model.model_info}")) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_analog_gain(config[CONF_ANALOG_GAIN])) + cg.add(var.set_dac_mode(config[CONF_DAC_MODE])) + cg.add(var.set_mixer_mode(config[CONF_MIXER_MODE])) + cg.add(var.set_volume_min_db(config[CONF_VOLUME_MIN_DB])) + cg.add(var.set_volume_max_db(config[CONF_VOLUME_MAX_DB])) + if enable_pin_config := config.get(CONF_ENABLE_PIN): + enable_pin = await cg.gpio_pin_expression(enable_pin_config) + cg.add(var.set_enable_pin(enable_pin)) diff --git a/esphome/components/tas58xx/binary_sensor.py b/esphome/components/tas58xx/binary_sensor.py new file mode 100644 index 00000000000..d0577398741 --- /dev/null +++ b/esphome/components/tas58xx/binary_sensor.py @@ -0,0 +1,86 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_MODEL, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import FILTER_SOURCE_FILES # noqa: F401 pylint: disable=unused-import +from .audio_dac import CONF_TAS58XX_ID, DAC_TAS5825M, TAS58xx, tas58xx_ns + +CONF_HAVE_FAULT = "have_fault" + +# Faults common to TAS5805M and TAS5825M +COMMON_FAULT_SENSORS = ( + "left_channel_dc_fault", + "right_channel_dc_fault", + "left_channel_over_current", + "right_channel_over_current", + "otp_crc_check", + "bq_write_failed", + "clock_fault", + "pvdd_over_voltage", + "pvdd_under_voltage", + "over_temp_shutdown", + "over_temp_warning", # tas582x OTW Level 3 - use tas5805 naming +) +# Additional faults available on TAS5825M +TAS5825M_ONLY_FAULT_SENSORS = ( + "load_eeprom_error", + "right_channel_cbc_over_current", + "left_channel_cbc_over_current", + "left_channel_cbc_over_current_warning", + "right_channel_cbc_over_current_warning", + "over_temp_146c_warning", # tas582x OTW Level 4 + # "over_temp_122c_warning", # tas582x OTW Level 2 - not currently included + # "over_temp_112c_warning", # tas582x OTW Level 1 - not currently included +) + +# Each name matches a FaultSensor value in tas58xx.h +FAULT_SENSORS = COMMON_FAULT_SENSORS + TAS5825M_ONLY_FAULT_SENSORS + +FaultSensor = tas58xx_ns.enum("FaultSensor") + +_FAULT_SCHEMA = binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PROBLEM, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_TAS58XX_ID): cv.use_id(TAS58xx), + cv.Optional(CONF_HAVE_FAULT): _FAULT_SCHEMA, + **{cv.Optional(key): _FAULT_SCHEMA for key in FAULT_SENSORS}, + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + fconf = fv.full_config.get() + hub_path = fconf.get_path_for_id(config[CONF_TAS58XX_ID]) + hub_conf = fconf.get_config_for_path(hub_path[:-1]) + + if hub_conf[CONF_MODEL] == DAC_TAS5825M: + return config + + unsupported = sorted(frozenset(TAS5825M_ONLY_FAULT_SENSORS).intersection(config)) + if unsupported: + raise cv.Invalid( + f"binary_sensor platform tas58xx: '{', '.join(unsupported)}' is only available for 'model: {DAC_TAS5825M}' " + f"- Remove from YAML for 'model: {hub_conf[CONF_MODEL]}'" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_TAS58XX_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAVE_FAULT, hub.set_have_fault_binary_sensor) + for key in FAULT_SENSORS: + if sensor_config := config.get(key): + sens = await binary_sensor.new_binary_sensor(sensor_config) + fault = getattr(FaultSensor, f"FAULT_SENSOR_{key.upper()}") + cg.add(hub.set_fault_binary_sensor(fault, sens)) diff --git a/esphome/components/tas58xx/model_tas5805m.cpp b/esphome/components/tas58xx/model_tas5805m.cpp new file mode 100644 index 00000000000..de0a0c5d750 --- /dev/null +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -0,0 +1,90 @@ +#include "tas58xx.h" + +#include "esphome/core/hal.h" + +namespace esphome::tas58xx { + +// Remainder of the startup sequence from TI PurePath Console, run after the reset. Register 0x00 selects the page. +// Registers 0x46, 0x7D, 0x7E and page 1 register 0x51 are not documented in the datasheet. +static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { + {0x03, 0x00}, // DEVICE_CTRL_2: deep sleep + {0x46, 0x01}, + {0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z + // The I2C address is latched at power up, after which the ADR pin can report faults + {0x61, 0x0B}, // ADR_PIN_CONFIG: FAULTZ + {0x60, 0x01}, // ADR_PIN_CTRL: output + {0x7D, 0x11}, + {0x7E, 0xFF}, + {0x00, 0x01}, + {0x51, 0x05}, + {0x00, 0x00}, +}; + +static const LogString *model_name() { return LOG_STR("TAS5805M"); } + +// 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 index) { + if (index == 0) + return LOG_STR("Right channel over current"); + if (index == 1) + return LOG_STR("Left channel over current"); + if (index == 2) + return LOG_STR("Right channel DC fault"); + if (index == 3) + return LOG_STR("Left channel DC fault"); + if (index == 8) + return LOG_STR("PVDD under voltage"); + if (index == 9) + return LOG_STR("PVDD over voltage"); + if (index == 10) + return LOG_STR("Clock fault"); + if (index == 14) + return LOG_STR("BQ write failed"); + if (index == 15) + return LOG_STR("OTP CRC check error"); + if (index == 16) + return LOG_STR("Over temperature shutdown"); + if (index == 26) + return LOG_STR("Over temperature warning"); + return LOG_STR("Unknown fault"); +} + +const ModelInfo TAS5805M_MODEL = { + .name = model_name, + .startup_sequence = STARTUP_SEQUENCE, + .startup_sequence_length = sizeof(STARTUP_SEQUENCE) / sizeof(STARTUP_SEQUENCE[0]), + .mixer_book = 0x8C, + .mixer_page = 0x29, + .mixer_register = 0x18, + // The clock fault is left out of the log and have_fault: it is set whenever the I2S clock stops, which is normal + .fault_error_mask = 0x0001C30F, + .fault_warning_mask = 0x04000000, + // DC and over current faults keep the output off until cleared (datasheet 7.5.3.3.1, 7.5.3.3.2). They are not + // cleared automatically: a DC fault re-trips only after 570 ms, so a clear on every poll would pass DC to the + // speaker. + .fault_output_off_mask = 0x0000000F, + .fault_latched_mask = 0x0401C70F, + .fault_name = fault_name, + .fault_sensor_bits = + { + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + NO_BIT, // FAULT_SENSOR_LOAD_EEPROM_ERROR + NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT + NO_BIT, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT + NO_BIT, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING + NO_BIT, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING + NO_BIT, // FAULT_SENSOR_OVER_TEMP_146C_WARNING + }, +}; + +} // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/model_tas5825m.cpp b/esphome/components/tas58xx/model_tas5825m.cpp new file mode 100644 index 00000000000..3aa74c675e5 --- /dev/null +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -0,0 +1,119 @@ +#include "tas58xx.h" + +#include "esphome/core/hal.h" + +namespace esphome::tas58xx { + +// Remainder of the startup sequence, run after the reset. Register 0x00 selects the page. +// All writes except starting and last three register writes are exactly the startup sequence from TI PurePath Console. +// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and Page 2 registers 0x1D, 0x19 are not documented in the datasheet. + +// clang-format off +static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { + {0x03, 0x00}, // Deep Sleep + {0x7D, 0x11}, + {0x7E, 0xFF}, + {0x00, 0x01}, // Page 1 + {0x51, 0x05}, + {0x00, 0x02}, // Page 2 + {0x1D, 0x00}, + {0x19, 0x80}, + {0x00, 0x00}, // Page 0 + {0x46, 0x11}, + {0x02, 0x00}, // DEVICE_CTRL_1 - BD MODE, Damp to PBL MODE + {0x53, 0x01}, // ANA_CTRL PWM Phase Control - in phase + {0x54, 0x00}, // AGAIN 0dB + {0x03, 0x02}, // Hi-Z + {0x61, 0x0B}, // GPIO0_SEL - GPIO0 as FAULTZ output + {0x60, 0x01}, // GPIO0_OE - Enable GPIO0 as output + {0x77, 0x07}, // CBC_CONTROL enabling CBC function for warnings and faults +}; +// clang-format on + +static const LogString *model_name() { return LOG_STR("TAS5825M"); } + +// 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 index) { + if (index == 0) + return LOG_STR("Right channel over current"); + if (index == 1) + return LOG_STR("Left channel over current"); + if (index == 2) + return LOG_STR("Right channel DC fault"); + if (index == 3) + return LOG_STR("Left channel DC fault"); + if (index == 8) + return LOG_STR("PVDD under voltage"); + if (index == 9) + return LOG_STR("PVDD over voltage"); + if (index == 10) + return LOG_STR("Clock fault"); + if (index == 13) + return LOG_STR("Load EEPROM error"); + if (index == 14) + return LOG_STR("BQ write failed"); + if (index == 15) + return LOG_STR("OTP CRC check error"); + if (index == 16) + return LOG_STR("Over temperature shutdown"); + if (index == 17) + return LOG_STR("Left channel CBC over current"); + if (index == 18) + return LOG_STR("Right channel CBC over current"); + // if (index == 24) + // return LOG_STR("Over temperature warning 112C"); // not currently included + // if (index == 25) + // return LOG_STR("Over temperature warning 122C"); // not currently included + if (index == 26) + return LOG_STR("Over temperature warning"); + if (index == 27) + return LOG_STR("Over temperature warning 146C"); + if (index == 28) + return LOG_STR("Right channel CBC over current warning"); + if (index == 29) + return LOG_STR("Left channel CBC over current warning"); + return LOG_STR("Unknown fault"); +} + +const ModelInfo TAS5825M_MODEL = { + .name = model_name, + .startup_sequence = STARTUP_SEQUENCE, + .startup_sequence_length = sizeof(STARTUP_SEQUENCE) / sizeof(STARTUP_SEQUENCE[0]), + .mixer_book = 0x8C, + .mixer_page = 0x0B, + .mixer_register = 0x14, + // The clock fault is left out of the log and have_fault: it is set whenever the I2S clock stops, which is normal + .fault_error_mask = 0x0007E30F, + .fault_warning_mask = + 0x3C000000, // 0x3F000000 if OVER_TEMP_122C_WARNING and OVER_TEMP_112C_WARNING included in future + // DC and over current faults keep the output off until cleared (datasheet 7.5.3.3.1, 7.5.3.3.2). They are not + // cleared automatically: a DC fault re-trips only after 570 ms, so a clear on every poll would pass DC to the + // speaker. + .fault_output_off_mask = 0x0000000F, + .fault_latched_mask = 0x3F07E70F, + .fault_name = fault_name, + .fault_sensor_bits = + { + 3, // FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT + 2, // FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT + 1, // FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT + 0, // FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT + 15, // FAULT_SENSOR_OTP_CRC_CHECK + 14, // FAULT_SENSOR_BQ_WRITE_FAILED + 10, // FAULT_SENSOR_CLOCK_FAULT + 9, // FAULT_SENSOR_PVDD_OVER_VOLTAGE + 8, // FAULT_SENSOR_PVDD_UNDER_VOLTAGE + 16, // FAULT_SENSOR_OVER_TEMP_SHUTDOWN + 26, // FAULT_SENSOR_OVER_TEMP_WARNING + 13, // FAULT_SENSOR_LOAD_EEPROM_ERROR + 18, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT + 17, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT + 29, // FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING + 28, // FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING + 27, // FAULT_SENSOR_OVER_TEMP_146C_WARNING + // 25, // FAULT_SENSOR_OVER_TEMP_122C_WARNING // not currently included + // 24, // FAULT_SENSOR_OVER_TEMP_112C_WARNING // not currently included + }, +}; + +} // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/tas58xx.cpp b/esphome/components/tas58xx/tas58xx.cpp new file mode 100644 index 00000000000..d042d42e24e --- /dev/null +++ b/esphome/components/tas58xx/tas58xx.cpp @@ -0,0 +1,338 @@ +#include "tas58xx.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::tas58xx { + +static const char *const TAG = "tas58xx"; + +static constexpr uint8_t TAS58XX_PAGE_SELECT = 0x00; // Page Select, in every book +static constexpr uint8_t TAS58XX_BOOK_SELECT = 0x7F; // Book Select, on page 0 of every book +static constexpr uint8_t TAS58XX_BOOK_CONTROL = 0x00; +static constexpr uint8_t TAS58XX_PAGE_0 = 0x00; + +/* BOOK 0x00, PAGE 0x00 */ +static constexpr uint8_t TAS58XX_RESET_CTRL = 0x01; +static constexpr uint8_t TAS58XX_RESET_CTRL_ALL = 0x11; // Reset DSP/control port and registers +static constexpr uint8_t TAS58XX_DEVICE_CTRL_1 = 0x02; +static constexpr uint8_t TAS58XX_DEVICE_CTRL_1_PBTL = (1 << 2); +static constexpr uint8_t TAS58XX_DEVICE_CTRL_2 = 0x03; +static constexpr uint8_t TAS58XX_DEVICE_CTRL_2_MUTE = (1 << 3); +static constexpr uint8_t TAS58XX_CTRL_STATE_MASK = 0x03; // DEVICE_CTRL_2 and POWER_STATE +static constexpr uint8_t TAS58XX_CTRL_STATE_DEEP_SLEEP = 0x00; +static constexpr uint8_t TAS58XX_CTRL_STATE_SLEEP = 0x01; +static constexpr uint8_t TAS58XX_CTRL_STATE_HIZ = 0x02; +static constexpr uint8_t TAS58XX_CTRL_STATE_PLAY = 0x03; +static constexpr uint8_t TAS58XX_CTRL_STATE_UNKNOWN = 0xFF; // Not a device value, forces the next update to act +static constexpr uint8_t TAS58XX_DIG_VOL = 0x4C; // 0x00 = +24 dB to 0xFE = -103 dB in 0.5 dB steps, 0xFF = mute +static constexpr uint8_t TAS58XX_DIG_VOL_0DB = 0x30; +static constexpr uint8_t TAS58XX_DIG_VOL_MINUS_103DB = 0xFE; +static constexpr uint8_t TAS58XX_AGAIN = 0x54; // 0x00 = 0 dB to 0x1F = -15.5 dB in 0.5 dB steps +static constexpr uint8_t TAS58XX_AGAIN_MINUS_15_5DB = 0x1F; +static constexpr uint8_t TAS58XX_POWER_STATE = 0x68; +static constexpr uint8_t TAS58XX_CHAN_FAULT = 0x70; // GLOBAL_FAULT1, GLOBAL_FAULT2 and OT_WARNING follow +static constexpr uint8_t TAS58XX_FAULT_REGISTER_COUNT = 4; +static constexpr uint8_t TAS58XX_FAULT_CLEAR = 0x78; +static constexpr uint8_t TAS58XX_FAULT_CLEAR_ANALOG = 0x80; + +/* Input mixer, four 9.23 fixed point big endian coefficients at a location that differs per model */ +static constexpr uint8_t TAS58XX_MIXER_COEFFICIENT_SIZE = 4; +// Only the second byte differs between mute, -6 dB and 0 dB +static constexpr uint8_t TAS58XX_MIXER_MUTE = 0x00; +static constexpr uint8_t TAS58XX_MIXER_MINUS_6DB = 0x40; +static constexpr uint8_t TAS58XX_MIXER_0DB = 0x80; + +static constexpr uint32_t PDN_LOW_MS = 1; +static constexpr uint32_t PDN_TO_I2C_MS = 5; // Minimum time from PDN high to I2C access +static constexpr uint32_t RESET_SETTLE_MS = 5; + +static const LogString *power_state_name(uint8_t state) { + if (state == TAS58XX_CTRL_STATE_DEEP_SLEEP) + return LOG_STR("Deep sleep"); + if (state == TAS58XX_CTRL_STATE_SLEEP) + return LOG_STR("Sleep"); + if (state == TAS58XX_CTRL_STATE_HIZ) + return LOG_STR("Hi-Z"); + return LOG_STR("Play"); +} + +void TAS58xx::setup() { + if (this->enable_pin_ != nullptr) { + this->enable_pin_->setup(); + this->enable_pin_->digital_write(false); + delay(PDN_LOW_MS); + this->enable_pin_->digital_write(true); + delay(PDN_TO_I2C_MS); + } + if (!this->init_()) { + this->mark_failed(); + } +} + +bool TAS58xx::select_book_page_(uint8_t book, uint8_t page) { + // The book can only be changed from page 0 + return this->write_byte(TAS58XX_PAGE_SELECT, TAS58XX_PAGE_0) && this->write_byte(TAS58XX_BOOK_SELECT, book) && + this->write_byte(TAS58XX_PAGE_SELECT, page); +} + +bool TAS58xx::init_() { + // Header of every TI PurePath Console export: select book 0 in case the MCU restarted without a PDN toggle, + // silence the output with Hi-Z, reset the DSP and the control registers, then return to Hi-Z + if (!this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0) || + !this->write_byte(TAS58XX_DEVICE_CTRL_2, TAS58XX_CTRL_STATE_HIZ) || + !this->write_byte(TAS58XX_RESET_CTRL, TAS58XX_RESET_CTRL_ALL) || + !this->write_byte(TAS58XX_DEVICE_CTRL_2, TAS58XX_CTRL_STATE_HIZ)) { + ESP_LOGE(TAG, "I2C write failed during reset"); + return false; + } + delay(RESET_SETTLE_MS); + + for (uint8_t i = 0; i < this->model_->startup_sequence_length; i++) { + const uint8_t *entry = this->model_->startup_sequence[i]; + if (!this->write_byte(progmem_read_byte(&entry[0]), progmem_read_byte(&entry[1]))) { + ESP_LOGE(TAG, "I2C write failed during init"); + return false; + } + } + + // Setters are public, so keep the value inside the register range + const uint8_t again = + static_cast(lroundf(clamp(-this->analog_gain_db_ * 2.0f, 0.0f, float{TAS58XX_AGAIN_MINUS_15_5DB}))); + if (!this->write_byte(TAS58XX_DEVICE_CTRL_1, this->dac_mode_ == DAC_MODE_PBTL ? TAS58XX_DEVICE_CTRL_1_PBTL : 0) || + !this->write_byte(TAS58XX_AGAIN, again) || !this->write_volume_() || + !this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, this->is_muted_) || + !this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) { + ESP_LOGE(TAG, "I2C write failed during init"); + return false; + } + // The mixer is written once the I2S clocks run, see on_audio_started() and update() + this->power_state_ = TAS58XX_CTRL_STATE_UNKNOWN; + this->mixer_written_ = false; + return true; +} + +void TAS58xx::activate() { + if (this->is_failed()) + return; + ESP_LOGD(TAG, "[0x%02X] Activating", this->address_); + // A failed mixer write can leave the device in another book + if (!this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0)) { + ESP_LOGE(TAG, "Failed to select the control port"); + return; + } + // Also the way to restart the output after a DC or over current fault + if (!this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) { + ESP_LOGW(TAG, "Failed to clear faults"); + } + const bool muted = this->is_muted_; + // Leaving deep sleep needs this sequence to reset the internal state machine, see datasheet 7.4.5 + if (this->ctrl_state_ == TAS58XX_CTRL_STATE_DEEP_SLEEP && + !(this->write_ctrl_state_(TAS58XX_CTRL_STATE_HIZ, muted) && + this->write_ctrl_state_(TAS58XX_CTRL_STATE_DEEP_SLEEP, muted) && + this->write_ctrl_state_(TAS58XX_CTRL_STATE_HIZ, muted))) { + return; + } + this->write_ctrl_state_(TAS58XX_CTRL_STATE_PLAY, muted); +} + +void TAS58xx::deactivate() { + if (this->is_failed()) + return; + ESP_LOGD(TAG, "[0x%02X] Deactivating", this->address_); + if (!this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0)) { + ESP_LOGE(TAG, "Failed to select the control port"); + return; + } + // The DSP, and with it the mixer, stays active in deep sleep + this->write_ctrl_state_(TAS58XX_CTRL_STATE_DEEP_SLEEP, this->is_muted_); +} + +bool TAS58xx::write_ctrl_state_(uint8_t state, bool muted) { + if (!this->write_byte(TAS58XX_DEVICE_CTRL_2, state | (muted ? TAS58XX_DEVICE_CTRL_2_MUTE : 0))) { + ESP_LOGE(TAG, "Failed to write DEVICE_CTRL_2"); + return false; + } + this->ctrl_state_ = state; + return true; +} + +// Returns false if the fault registers could not be read +bool TAS58xx::read_faults_() { + uint8_t fault_registers[TAS58XX_FAULT_REGISTER_COUNT]; + if (!this->read_bytes(TAS58XX_CHAN_FAULT, fault_registers, sizeof(fault_registers))) + return false; + uint32_t faults = 0; + for (uint8_t reg = 0; reg < TAS58XX_FAULT_REGISTER_COUNT; reg++) + faults |= uint32_t{fault_registers[reg]} << (reg * 8); + const ModelInfo &model = *this->model_; + const uint32_t active = faults & (model.fault_error_mask | model.fault_warning_mask); + + // Clearing makes a lasting condition latch again on every poll, so only log changes + const uint32_t changed = active ^ this->logged_faults_; + for (uint8_t index = 0; index < 32; index++) { + const uint32_t mask = uint32_t{1} << index; + if (!(changed & mask)) + continue; + const LogString *name = model.fault_name(index); + if (!(active & mask)) { + ESP_LOGI(TAG, "[0x%02X] %s cleared", this->address_, LOG_STR_ARG(name)); + } else if (model.fault_error_mask & mask) { + ESP_LOGE(TAG, "[0x%02X] %s", this->address_, LOG_STR_ARG(name)); + } else { + ESP_LOGW(TAG, "[0x%02X] %s", this->address_, LOG_STR_ARG(name)); + } + } + if (changed & active & model.fault_output_off_mask) { + ESP_LOGW(TAG, "[0x%02X] Output stays off: fix the cause, then call tas58xx.activate or power cycle the amplifier", + this->address_); + } + this->logged_faults_ = active; + +#ifdef USE_BINARY_SENSOR + if (this->have_fault_binary_sensor_ != nullptr) + this->have_fault_binary_sensor_->publish_state((active & model.fault_error_mask) != 0); + for (uint8_t fault = 0; fault < FAULT_SENSOR_COUNT; fault++) { + const uint8_t bit = model.fault_sensor_bits[fault]; + if (this->fault_binary_sensors_[fault] != nullptr && bit != NO_BIT) + this->fault_binary_sensors_[fault]->publish_state(faults & (uint32_t{1} << bit)); + } +#endif + + // Latched fault bits stay set after the condition is gone and only report it; clear those so the bits, and the + // sensors, follow the current state. The clear register resets all faults at once, so hold off while the output + // is off. + if ((faults & model.fault_latched_mask) && !(active & model.fault_output_off_mask) && + !this->write_byte(TAS58XX_FAULT_CLEAR, TAS58XX_FAULT_CLEAR_ANALOG)) { + ESP_LOGW(TAG, "Failed to clear faults"); + } + return true; +} + +void TAS58xx::on_audio_started() { + // DSP coefficients can only be written with a running I2S clock (datasheet 7.5.3.1) and are kept when it stops + if (this->is_failed() || this->mixer_written_) + return; + if (!this->write_mixer_()) { + ESP_LOGW(TAG, "Failed to write mixer"); + } +} + +void TAS58xx::update() { + uint8_t power_state; + if (!this->read_faults_() || !this->read_byte(TAS58XX_POWER_STATE, &power_state)) { + this->status_set_warning(LOG_STR("Failed to read status")); + return; + } + this->status_clear_warning(); + + power_state &= TAS58XX_CTRL_STATE_MASK; + if (power_state != this->power_state_) { + ESP_LOGD(TAG, "[0x%02X] Power state: %s", this->address_, LOG_STR_ARG(power_state_name(power_state))); + this->power_state_ = power_state; + } + // The device only plays with a running I2S clock. Fallback for speakers that do not call on_audio_started(); + // a failed write is retried on the next update. + if (power_state == TAS58XX_CTRL_STATE_PLAY && !this->mixer_written_ && !this->write_mixer_()) { + ESP_LOGW(TAG, "Failed to write mixer"); + } +} + +void TAS58xx::dump_config() { + const LogString *mixer_mode; + if (this->mixer_mode_ == MIXER_MODE_STEREO_INVERSE) { + mixer_mode = LOG_STR("Stereo inverse"); + } else if (this->mixer_mode_ == MIXER_MODE_MONO) { + mixer_mode = LOG_STR("Mono"); + } else if (this->mixer_mode_ == MIXER_MODE_LEFT) { + mixer_mode = LOG_STR("Left"); + } else if (this->mixer_mode_ == MIXER_MODE_RIGHT) { + mixer_mode = LOG_STR("Right"); + } else { + mixer_mode = LOG_STR("Stereo"); + } + ESP_LOGCONFIG(TAG, + "Audio Amplifier:\n" + " Model: %s", + LOG_STR_ARG(this->model_->name())); + LOG_I2C_DEVICE(this); + LOG_PIN(" Enable Pin: ", this->enable_pin_); + LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " Analog Gain: %.1f dB\n" + " DAC Mode: %s\n" + " Mixer Mode: %s\n" + " Volume Range: %.1f dB to %.1f dB", + this->analog_gain_db_, + this->dac_mode_ == DAC_MODE_PBTL ? LOG_STR_LITERAL("PBTL") : LOG_STR_LITERAL("BTL"), + LOG_STR_ARG(mixer_mode), this->volume_min_db_, this->volume_max_db_); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Any Fault", this->have_fault_binary_sensor_); + for (auto *sensor : this->fault_binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Fault", sensor); + } +#endif +} + +bool TAS58xx::set_mute_(bool muted) { + if (!this->write_ctrl_state_(this->ctrl_state_, muted)) + return false; + this->is_muted_ = muted; + return true; +} + +bool TAS58xx::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 TAS58xx::write_volume_() { + // volume 0.0 maps to volume_min_db_, which is only close to silence at -103 dB + const float volume_db = std::lerp(this->volume_min_db_, this->volume_max_db_, this->volume_); + const uint8_t dig_vol = static_cast( + lroundf(clamp(TAS58XX_DIG_VOL_0DB - volume_db * 2.0f, 0.0f, float{TAS58XX_DIG_VOL_MINUS_103DB}))); + ESP_LOGV(TAG, "Setting volume to 0x%02X", dig_vol); + return this->write_byte(TAS58XX_DIG_VOL, dig_vol); +} + +bool TAS58xx::write_mixer_() { + uint8_t left_to_left = TAS58XX_MIXER_0DB; + uint8_t right_to_left = TAS58XX_MIXER_MUTE; + uint8_t left_to_right = TAS58XX_MIXER_MUTE; + uint8_t right_to_right = TAS58XX_MIXER_0DB; + if (this->mixer_mode_ == MIXER_MODE_STEREO_INVERSE) { + left_to_left = TAS58XX_MIXER_MUTE; + right_to_left = TAS58XX_MIXER_0DB; + left_to_right = TAS58XX_MIXER_0DB; + right_to_right = TAS58XX_MIXER_MUTE; + } else if (this->mixer_mode_ == MIXER_MODE_MONO) { + left_to_left = TAS58XX_MIXER_MINUS_6DB; + right_to_left = TAS58XX_MIXER_MINUS_6DB; + left_to_right = TAS58XX_MIXER_MINUS_6DB; + right_to_right = TAS58XX_MIXER_MINUS_6DB; + } else if (this->mixer_mode_ == MIXER_MODE_LEFT) { + left_to_right = TAS58XX_MIXER_0DB; + right_to_right = TAS58XX_MIXER_MUTE; + } else if (this->mixer_mode_ == MIXER_MODE_RIGHT) { + left_to_left = TAS58XX_MIXER_MUTE; + right_to_left = TAS58XX_MIXER_0DB; + } + const uint8_t coefficients[4 * TAS58XX_MIXER_COEFFICIENT_SIZE] = {0, left_to_left, 0, 0, 0, right_to_left, 0, 0, + 0, left_to_right, 0, 0, 0, right_to_right, 0, 0}; + bool ok = this->select_book_page_(this->model_->mixer_book, this->model_->mixer_page) && + this->write_bytes(this->model_->mixer_register, coefficients, sizeof(coefficients)); + // Always return to the control port, even after a failed write + ok = this->select_book_page_(TAS58XX_BOOK_CONTROL, TAS58XX_PAGE_0) && ok; + this->mixer_written_ = ok; + return ok; +} + +} // namespace esphome::tas58xx diff --git a/esphome/components/tas58xx/tas58xx.h b/esphome/components/tas58xx/tas58xx.h new file mode 100644 index 00000000000..d0f97a5cd9f --- /dev/null +++ b/esphome/components/tas58xx/tas58xx.h @@ -0,0 +1,164 @@ +#pragma once + +#include + +#include "esphome/components/audio_dac/audio_dac.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::tas58xx { + +enum DacMode : uint8_t { + DAC_MODE_BTL = 0, // Bridge tied load, two speakers + DAC_MODE_PBTL = 1, // Parallel bridge tied load, one speaker +}; + +enum MixerMode : uint8_t { + MIXER_MODE_STEREO = 0, + MIXER_MODE_STEREO_INVERSE, + MIXER_MODE_MONO, + MIXER_MODE_LEFT, + MIXER_MODE_RIGHT, +}; + +/// Fault binary sensors that map to a single fault bit. Note that ordering is important. +/// The Python FAULT_SENSORS list in binary_sensor.py uses the same names and ordering. +enum FaultSensor : uint8_t { + // Faults common to TAS5805M and TAS5825M + FAULT_SENSOR_LEFT_CHANNEL_DC_FAULT, + FAULT_SENSOR_RIGHT_CHANNEL_DC_FAULT, + FAULT_SENSOR_LEFT_CHANNEL_OVER_CURRENT, + FAULT_SENSOR_RIGHT_CHANNEL_OVER_CURRENT, + FAULT_SENSOR_OTP_CRC_CHECK, + FAULT_SENSOR_BQ_WRITE_FAILED, + FAULT_SENSOR_CLOCK_FAULT, + FAULT_SENSOR_PVDD_OVER_VOLTAGE, + FAULT_SENSOR_PVDD_UNDER_VOLTAGE, + FAULT_SENSOR_OVER_TEMP_SHUTDOWN, + FAULT_SENSOR_OVER_TEMP_WARNING, + // Faults available for TAS5825M Only + FAULT_SENSOR_LOAD_EEPROM_ERROR, + FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT, + FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT, + FAULT_SENSOR_LEFT_CHANNEL_CBC_OVER_CURRENT_WARNING, + FAULT_SENSOR_RIGHT_CHANNEL_CBC_OVER_CURRENT_WARNING, + FAULT_SENSOR_OVER_TEMP_146C_WARNING, + // FAULT_SENSOR_OVER_TEMP_122C_WARNING, + // FAULT_SENSOR_OVER_TEMP_112C_WARNING, + FAULT_SENSOR_COUNT, // keep last +}; + +// compile time check +static_assert( + FAULT_SENSOR_COUNT == 17, + "enum FaultSensor altered: Update FAULT_SENSORS in binary_sensor.py and fault_sensor_bits in each model's cpp"); + +/// ModelInfo::fault_sensor_bits value for a FaultSensor that the model does not have. +static constexpr uint8_t NO_BIT = 0xFF; + +/// Everything that differs between models of the family. One constant instance exists per model, see +/// model_*.cpp, and each TAS58xx instance points to the one for its model. +/// +/// Fault bits are packed into a 32 bit word: CHAN_FAULT, GLOBAL_FAULT1, GLOBAL_FAULT2 and OT_WARNING, one byte +/// each, from low to high. A bit index is register * 8 + bit. +struct ModelInfo { + const LogString *(*name)(); + /// Remainder of the startup sequence, run after the reset, as {register, value} pairs in PROGMEM + const uint8_t (*startup_sequence)[2]; + uint8_t startup_sequence_length; + /// Location of the four 9.23 fixed point input mixer coefficients: LEFT_TO_LEFT, RIGHT_TO_LEFT, LEFT_TO_RIGHT, + /// RIGHT_TO_RIGHT + uint8_t mixer_book; + uint8_t mixer_page; + uint8_t mixer_register; + /// Faults logged as errors and reported by have_fault + uint32_t fault_error_mask; + /// Faults logged as warnings + uint32_t fault_warning_mask; + /// Faults that keep the output off until cleared by activate() + uint32_t fault_output_off_mask; + /// Faults that stay set after the condition is gone and are cleared after each read + uint32_t fault_latched_mask; + const LogString *(*fault_name)(uint8_t index); + /// Bit index for each FaultSensor, NO_BIT used where the model does not have that fault bit + uint8_t fault_sensor_bits[FAULT_SENSOR_COUNT]; +}; + +#ifdef USE_TAS58XX_TAS5805M +extern const ModelInfo TAS5805M_MODEL; +#endif +#ifdef USE_TAS58XX_TAS5825M +extern const ModelInfo TAS5825M_MODEL; +#endif + +class TAS58xx : public audio_dac::AudioDac, public PollingComponent, public i2c::I2CDevice { + public: + explicit TAS58xx(const ModelInfo *model) : model_(model) {} + + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + void update() override; + + /// Clear faults, leave deep sleep and switch to play; the device waits in Hi-Z until an I2S clock is present. + /// This is also how the output is restarted after a DC or over current fault. + void activate(); + /// Switch to deep sleep, the lowest power state that keeps I2C and the DSP running. + void deactivate(); + + 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 on_audio_started() override; + + void set_enable_pin(GPIOPin *enable_pin) { this->enable_pin_ = enable_pin; } + void set_analog_gain(float analog_gain_db) { this->analog_gain_db_ = analog_gain_db; } + void set_dac_mode(DacMode dac_mode) { this->dac_mode_ = dac_mode; } + void set_mixer_mode(MixerMode mixer_mode) { this->mixer_mode_ = mixer_mode; } + void set_volume_min_db(float volume_min_db) { this->volume_min_db_ = volume_min_db; } + void set_volume_max_db(float volume_max_db) { this->volume_max_db_ = volume_max_db; } + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(have_fault) + void set_fault_binary_sensor(FaultSensor fault, binary_sensor::BinarySensor *sensor) { + this->fault_binary_sensors_[fault] = sensor; + } +#endif + + protected: + bool select_book_page_(uint8_t book, uint8_t page); + bool init_(); + bool write_ctrl_state_(uint8_t state, bool muted); + bool set_mute_(bool muted); + bool write_volume_(); + bool write_mixer_(); + bool read_faults_(); + + const ModelInfo *model_; + GPIOPin *enable_pin_{nullptr}; +#ifdef USE_BINARY_SENSOR + std::array fault_binary_sensors_{}; +#endif + float volume_{0}; + float analog_gain_db_{-15.5f}; + float volume_min_db_{-103.0f}; + float volume_max_db_{24.0f}; + uint32_t logged_faults_{0}; // Fault bits as last logged, packed like the fault masks + DacMode dac_mode_{DAC_MODE_BTL}; + MixerMode mixer_mode_{MIXER_MODE_STEREO}; + uint8_t ctrl_state_{0}; + uint8_t power_state_{0xFF}; // Last POWER_STATE seen by update(), 0xFF until the first read + bool mixer_written_{false}; // Mixer written since the last reset +}; + +} // namespace esphome::tas58xx diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 3033cc65e08..6ae1afc883b 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -62,7 +62,7 @@ TCA9555_PIN_SCHEMA = pins.gpio_base_schema( invertible=True, ).extend( { - cv.Required(CONF_TCA9555): cv.use_id(TCA9555Component), + cv.Required(CONF_TCA9555): pins.use_id_or_address(TCA9555Component), } ) diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py new file mode 100644 index 00000000000..796a9fc48db --- /dev/null +++ b/esphome/components/tcp_uart/__init__.py @@ -0,0 +1,105 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components.const import ( + CONF_ALLOWED_IPS, + CONF_CONNECTED, + CONF_DATA_BITS, + CONF_HOST, + CONF_PARITY, + CONF_RECONNECT_INTERVAL, + CONF_ROLE, + CONF_STOP_BITS, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_BAUD_RATE, + CONF_ID, + CONF_PORT, + DEVICE_CLASS_CONNECTIVITY, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@Bascht74"] +DEPENDENCIES = ["network"] +AUTO_LOAD = ["uart", "binary_sensor", "sensor", "socket"] +MULTI_CONF = True + +CONF_DISCONNECTS = "disconnects" + +tcp_uart_ns = cg.esphome_ns.namespace("tcp_uart") +TcpUart = tcp_uart_ns.class_("TcpUart", uart.UARTComponent, cg.Component) + + +BASE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TcpUart), + cv.Required(CONF_PORT): cv.port, + cv.Optional(CONF_BAUD_RATE, default=9600): cv.int_range(min=1), + cv.Optional(CONF_DATA_BITS, default=8): cv.int_range(min=5, max=8), + cv.Optional(CONF_PARITY, default="NONE"): cv.enum( + uart.UART_PARITY_OPTIONS, upper=True + ), + cv.Optional(CONF_STOP_BITS, default=1): cv.one_of(1, 2, int=True), + cv.Optional( + CONF_RECONNECT_INTERVAL, default="5s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "client": BASE_SCHEMA.extend( + { + cv.Required(CONF_HOST): socket.ipv4_host, + } + ), + "server": BASE_SCHEMA.extend( + { + cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA, + } + ), + }, + key=CONF_ROLE, + default_type="client", + lower=True, + ), + socket.consume_role_sockets("tcp_uart"), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + if config[CONF_ROLE] == "server": + socket.require_tcp_listener() + cg.add(var.set_server(True)) + socket.add_ipv4_allow( + var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID] + ) + else: + socket.require_tcp_client_link() + cg.add(var.set_port(config[CONF_PORT])) + cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) + # The socket is not clocked. These only satisfy UARTComponent and a consumer check. + cg.add(var.set_baud_rate(config[CONF_BAUD_RATE])) + cg.add(var.set_data_bits(config[CONF_DATA_BITS])) + cg.add(var.set_stop_bits(config[CONF_STOP_BITS])) + cg.add(var.set_parity(config[CONF_PARITY])) + if (host := config.get(CONF_HOST)) is not None: + cg.add(var.set_host(host)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/tcp_uart/tcp_uart.cpp b/esphome/components/tcp_uart/tcp_uart.cpp new file mode 100644 index 00000000000..efc85804e99 --- /dev/null +++ b/esphome/components/tcp_uart/tcp_uart.cpp @@ -0,0 +1,160 @@ +#include "tcp_uart.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::tcp_uart { + +static const char *const TAG = "tcp_uart"; + +static constexpr uint32_t DROP_LOG_INTERVAL_MS = 5000; + +void TcpUart::setup() { + this->link_.begin(TAG); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.begin(TAG); +#endif + if (this->connected_sensor_ != nullptr) { + this->connected_sensor_->publish_state(false); + } +#ifdef USE_SENSOR + if (this->disconnects_sensor_ != nullptr) { + this->disconnects_sensor_->publish_state(0); + } +#endif +} + +void TcpUart::dump_config() { + ESP_LOGCONFIG(TAG, + "TCP UART:\n" + " %s: %s:%u\n" + " Reconnect Interval: %" PRIu32 "ms", + this->server_ ? LOG_STR_LITERAL("Listen") : LOG_STR_LITERAL("Host"), + this->server_ ? LOG_STR_LITERAL("*") : this->link_.host(), this->link_.port(), + this->link_.reconnect_interval()); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.dump_config(); +#endif + LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); +#endif +} + +void TcpUart::on_shutdown() { + this->link_.close(); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.close(); +#endif +} + +void TcpUart::sync_link_() { + bool up = this->link_.connected(); + this->link_was_up_ = up; + if (up) { + // Unread bytes of the last session stay readable while down, never into the next one. + this->rx_start_ = this->rx_end_ = 0; + } + if (this->connected_sensor_ != nullptr) { + this->connected_sensor_->publish_state(up); + } +#ifdef USE_SENSOR + // Only edges get here, so down is the falling edge. + if (!up && this->disconnects_sensor_ != nullptr) { + this->disconnects_++; + this->disconnects_sensor_->publish_state(this->disconnects_); + } +#endif +} + +void TcpUart::read_socket_() { + if (this->rx_start_ != 0) { + this->rx_end_ -= this->rx_start_; + std::memmove(this->rx_, this->rx_ + this->rx_start_, this->rx_end_); + this->rx_start_ = 0; + } + size_t room = RX_BUFFER_SIZE - this->rx_end_; + if (room == 0) { + // Only a read that filled all free space gets here, so rx_pending_ is already set. + return; + } + ssize_t count = this->link_.read(this->rx_ + this->rx_end_, room); + if (count <= 0) { + // A dropped link (-1) is seen by sync_link_() on the next loop. + if (count == 0) { + this->rx_pending_ = false; + } + return; + } + this->rx_end_ += static_cast(count); + this->rx_pending_ = static_cast(count) == room; +} + +void TcpUart::loop() { +#ifdef USE_SOCKET_TCP_LISTENER + if (this->server_) { + // link_was_up_ holds the accept until the previous drop's edge has run, + // so the sensor sees the disconnect and the new session's edge clears RX. + this->listener_.poll(this->link_, !this->link_was_up_); + } else { + this->link_.poll(); + } +#else + this->link_.poll(); +#endif + if (this->link_.connected() != this->link_was_up_) { + this->sync_link_(); + } + if (!this->link_was_up_) { + return; + } + if (this->rx_pending_ || this->link_.ready()) { + this->read_socket_(); + } + this->link_.flush_tx(); +} + +void TcpUart::write_array(const uint8_t *data, size_t len) { + size_t queued = this->link_.queue(data, len); + if (queued < len) { + uint32_t now = App.get_loop_component_start_time(); + if (this->last_drop_log_ms_ == 0 || now - this->last_drop_log_ms_ >= DROP_LOG_INTERVAL_MS) { + ESP_LOGW(TAG, "%s, dropped %u bytes", + this->link_.connected() ? LOG_STR_LITERAL("TX buffer full") : LOG_STR_LITERAL("Not connected"), + static_cast(len - queued)); + this->last_drop_log_ms_ = now; + } + } +} + +bool TcpUart::peek_byte(uint8_t *data) { + if (this->rx_start_ == this->rx_end_) { + return false; + } + *data = this->rx_[this->rx_start_]; + return true; +} + +bool TcpUart::read_array(uint8_t *data, size_t len) { + if (this->available() < len) { + return false; + } + std::memcpy(data, this->rx_ + this->rx_start_, len); + this->rx_start_ += static_cast(len); + return true; +} + +uart::UARTFlushResult TcpUart::flush() { + bool emptied = this->link_.flush_tx(); + if (!this->link_.connected()) { + // A down link cannot have delivered anything, whether this flush dropped + // it or an earlier loop() write did. + return uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED; + } + return emptied ? uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS : uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT; +} + +} // namespace esphome::tcp_uart diff --git a/esphome/components/tcp_uart/tcp_uart.h b/esphome/components/tcp_uart/tcp_uart.h new file mode 100644 index 00000000000..1e1d48e4c20 --- /dev/null +++ b/esphome/components/tcp_uart/tcp_uart.h @@ -0,0 +1,84 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/components/socket/tcp_client_link.h" +#ifdef USE_SOCKET_TCP_LISTENER +#include "esphome/components/socket/tcp_listener.h" +#endif +#include "esphome/components/uart/uart_component.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::tcp_uart { + +/// TCP client or server presented as a UART. Bytes are copied unchanged. +class TcpUart : public uart::UARTComponent, public Component { + public: + TcpUart() { this->rx_buffer_size_ = RX_BUFFER_SIZE; } + + void set_host(const char *host) { this->link_.set_host(host); } + void set_port(uint16_t port) { this->link_.set_port(port); } + void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } + void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#ifdef USE_SENSOR + void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } +#endif +#ifdef USE_SOCKET_TCP_LISTENER + void set_server(bool server) { this->server_ = server; } +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->listener_.set_allow(entries, count); } +#endif +#endif + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + size_t available() override { return static_cast(this->rx_end_ - this->rx_start_); } + // Same room write_array() grants, so consumers can apply backpressure. + size_t available_for_write() override { return this->link_.tx_free(); } + uart::UARTFlushResult flush() override; + bool is_connected() override { return this->link_.connected(); } +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} + using UARTComponent::load_settings; // also bring in the no-arg overload for convenience +#endif + + protected: + void check_logger_conflict() override {} + void sync_link_(); + void read_socket_(); + + static constexpr size_t RX_BUFFER_SIZE = 1024; + + socket::TcpClientLink link_; +#ifdef USE_SOCKET_TCP_LISTENER + socket::TcpListener listener_; +#endif + binary_sensor::BinarySensor *connected_sensor_{nullptr}; +#ifdef USE_SENSOR + sensor::Sensor *disconnects_sensor_{nullptr}; + uint32_t disconnects_{0}; +#endif + uint32_t last_drop_log_ms_{0}; + // rx_[rx_start_, rx_end_) holds unread bytes; read_socket_() compacts to the front. + uint16_t rx_start_{0}; + uint16_t rx_end_{0}; + bool server_{false}; + // The link state loop() saw last; edges publish the sensor, the up edge clears rx_. + bool link_was_up_{false}; + // A read stopped before EAGAIN. ready() stays false until new data arrives. + bool rx_pending_{false}; + uint8_t rx_[RX_BUFFER_SIZE]{}; +}; + +} // namespace esphome::tcp_uart 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/tem3200/sensor.py b/esphome/components/tem3200/sensor.py index 289641f951f..2b1839c529f 100644 --- a/esphome/components/tem3200/sensor.py +++ b/esphome/components/tem3200/sensor.py @@ -46,10 +46,6 @@ 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)) - - if raw_pressure_config := config.get(CONF_RAW_PRESSURE): - sens = await sensor.new_sensor(raw_pressure_config) - cg.add(var.set_raw_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_RAW_PRESSURE, var.set_raw_pressure_sensor) diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 07028f7dffc..b2d6eca260c 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -60,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/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 0e6f96e9f5b..39df5affcf2 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -20,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 @@ -120,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), @@ -142,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/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/number/template_number.h b/esphome/components/template/number/template_number.h index e51e858ccf3..2c4932f7e87 100644 --- a/esphome/components/template/number/template_number.h +++ b/esphome/components/template/number/template_number.h @@ -25,8 +25,8 @@ class TemplateNumber final : public number::Number, public PollingComponent { protected: void control(float value) override; bool optimistic_{false}; - float initial_value_{NAN}; bool restore_value_{false}; + float initial_value_{NAN}; Trigger set_trigger_; TemplateLambda f_; diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 1cc28a36d37..c6a1b8bd088 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -9,7 +9,8 @@ namespace esphome::template_ { -struct Empty {}; +// Distinct per member: two empty members of the same type cannot share an address +template struct Empty {}; class BaseTemplateSelect : public select::Select, public PollingComponent {}; void dump_config_helper(BaseTemplateSelect *sel_comp, bool optimistic, bool has_lambda, size_t initial_option_index, @@ -59,8 +60,8 @@ class TemplateSelect : public BaseTemplateSelect { if constexpr (RESTORE_VALUE) this->pref_.save(&index); } - [[no_unique_address]] std::conditional_t, Empty> f_{}; - [[no_unique_address]] std::conditional_t pref_{}; + [[no_unique_address]] std::conditional_t, Empty<0>> f_{}; + [[no_unique_address]] std::conditional_t> pref_{}; }; /// Template select with set_action trigger - only instantiated when set_action is configured diff --git a/esphome/components/template/sensor/__init__.py b/esphome/components/template/sensor/__init__.py index 55537a56369..f831f87d92b 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -49,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/switch/__init__.py b/esphome/components/template/switch/__init__.py index 0b246869363..f8e509faf36 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -79,20 +79,13 @@ async def to_code(config): 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/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index 77f5c2ff7cf..32e616e6f9f 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -1,7 +1,6 @@ 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_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE @@ -38,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/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 11b35dad23a..a7fd6f7632c 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -7,7 +7,6 @@ from esphome.const import ( CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, CONF_DEVICE_CLASS, - CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, CONF_OPTIMISTIC, @@ -22,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, @@ -104,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), @@ -117,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/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 9d6a3523d28..7718688d68a 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -91,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 5c8d71696f5..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) @@ -238,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/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/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 7ad88d3018d..1b819088cad 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -12,8 +12,6 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] @@ -38,7 +36,6 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) -IsMountedCondition = tinyusb_ns.class_("IsMountedCondition", automation.Condition) _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( @@ -136,16 +133,8 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) -@automation.register_condition( +automation.register_apply_condition( "tinyusb.is_mounted", - IsMountedCondition, cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), + "is_mounted()", ) -async def tinyusb_is_mounted_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) diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index f7f574ec6d5..030356bc03e 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,7 +1,6 @@ #pragma once #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/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -87,15 +86,6 @@ class TinyUSB final : public Component { }; }; -template class IsMountedCondition final : public Condition { - public: - explicit IsMountedCondition(TinyUSB *parent) : parent_(parent) {} - bool check(const Ts &...) override { return this->parent_->is_mounted(); } - - protected: - TinyUSB *parent_; -}; - } // namespace esphome::tinyusb #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 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/touchscreen/touchscreen.cpp b/esphome/components/touchscreen/touchscreen.cpp index f4ef66ef3e0..cb36733e2f5 100644 --- a/esphome/components/touchscreen/touchscreen.cpp +++ b/esphome/components/touchscreen/touchscreen.cpp @@ -5,6 +5,7 @@ namespace esphome::touchscreen { static const char *const TAG = "touchscreen"; +static constexpr uint32_t TOUCH_TIMEOUT_ID = 0; void TouchscreenInterrupt::gpio_intr(TouchscreenInterrupt *store) { store->touched = true; } @@ -63,9 +64,9 @@ void Touchscreen::loop() { // Simulate a touch after touch_timeout_> ms. This will reset any existing timeout operation. // This is to detect touch release. if (this->is_touched_) { - this->set_timeout(TAG, this->touch_timeout_, [this]() { this->store_.touched = true; }); + this->set_timeout(TOUCH_TIMEOUT_ID, this->touch_timeout_, [this]() { this->store_.touched = true; }); } else { - this->cancel_timeout(TAG); + this->cancel_timeout(TOUCH_TIMEOUT_ID); } } } 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/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/tx20/sensor.py b/esphome/components/tx20/sensor.py index 3a24b8dc0b6..77418e5284f 100644 --- a/esphome/components/tx20/sensor.py +++ b/esphome/components/tx20/sensor.py @@ -44,15 +44,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_WIND_SPEED in config: - conf = config[CONF_WIND_SPEED] - sens = await sensor.new_sensor(conf) - cg.add(var.set_wind_speed_sensor(sens)) - - if CONF_WIND_DIRECTION_DEGREES in config: - conf = config[CONF_WIND_DIRECTION_DEGREES] - sens = await sensor.new_sensor(conf) - cg.add(var.set_wind_direction_degrees_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_WIND_SPEED, var.set_wind_speed_sensor) + await sensors(CONF_WIND_DIRECTION_DEGREES, var.set_wind_direction_degrees_sensor) pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 78633bcf6a8..25fbfb75994 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, @@ -30,6 +46,7 @@ from esphome.const import ( CONF_SEQUENCE, CONF_TIMEOUT, CONF_TRIGGER_ID, + CONF_TX_BUFFER_SIZE, CONF_TX_PIN, CONF_UART_ID, PLATFORM_HOST, @@ -179,6 +196,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 = { @@ -254,6 +298,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_PORT): cv.All(validate_port, cv.only_on(PLATFORM_HOST)), cv.Optional(CONF_RX_BUFFER_SIZE, default=256): cv.validate_bytes, + cv.Optional(CONF_TX_BUFFER_SIZE): cv.All( + cv.only_on_esp32, cv.validate_bytes, cv.int_range(min=129) + ), cv.Optional(CONF_RX_FULL_THRESHOLD): cv.All( cv.only_on_esp32, cv.validate_bytes, cv.int_range(min=1, max=120) ), @@ -263,6 +310,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,9 +395,16 @@ 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])) + if (tx_buffer_size := config.get(CONF_TX_BUFFER_SIZE)) is not None: + cg.add(var.set_tx_buffer_size(tx_buffer_size)) + # 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])) + # Skip the setter when the config matches the C++ initializer (UART_CONFIG_PARITY_NONE). + if (parity := config[CONF_PARITY]) != "NONE": + cg.add(var.set_parity(parity)) if CONF_DEBUG in config: await debug_to_code(config[CONF_DEBUG], var) @@ -431,7 +489,8 @@ def final_validate_device_schema( return value def validate_stop_bits(value): - if value != stop_bits: + # usb_uart channels store stop bits as strings ("1", "1.5", "2"). + if float(value) != stop_bits: raise cv.Invalid( f"Component {name} requires {stop_bits} stop bits for the uart referenced by {uart_bus}" ) diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index eda5b72ea82..d1c67733634 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -37,9 +37,10 @@ class UARTDevice { bool read_array(uint8_t *data, size_t len) { return this->parent_->read_array(data, len); } template optional> read_array() { // NOLINT - std::array res; - if (!this->read_array(res.data(), N)) { - return {}; + // One object returned on both paths; a separate empty optional trips GCC's -Wmaybe-uninitialized. + optional> res(std::in_place); + if (!this->read_array(res->data(), N)) { + res.reset(); } return res; } diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 3e525317917..8f143b79777 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "esphome/core/defines.h" #include "esphome/core/component.h" @@ -81,6 +82,10 @@ class UARTComponent { // @return Number of available bytes. virtual size_t available() = 0; + // Returns how many bytes write_array() accepts right now without blocking. + // Platforms that cannot tell return SIZE_MAX: write_array() takes everything and may block. + virtual size_t available_for_write() { return SIZE_MAX; } + // Pure virtual method to block until all bytes have been written to the UART bus. // @return UARTFlushResult indicating whether the flush was confirmed, timed out, failed, or assumed successful. virtual UARTFlushResult flush() = 0; @@ -212,7 +217,7 @@ class UARTComponent { uint32_t baud_rate_{0}; uint8_t stop_bits_{0}; uint8_t data_bits_{0}; - UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; + UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; // Must match the parity check in __init__.py #ifdef USE_UART_DEBUGGER CallbackManager debug_callback_{}; #endif diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 2f8b4dbd117..0f6d7f6577c 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" @@ -220,6 +221,12 @@ size_t ESP8266UartComponent::available() { return this->sw_serial_->available(); } } +size_t ESP8266UartComponent::available_for_write() { + if (this->hw_serial_ != nullptr) { + return this->hw_serial_->availableForWrite(); + } + return SIZE_MAX; // software serial bit-bangs each byte synchronously; there is no buffer to fill +} UARTFlushResult ESP8266UartComponent::flush() { ESP_LOGVV(TAG, " Flushing"); if (this->hw_serial_ != nullptr) { @@ -251,23 +258,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 +315,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..0098e752b2d 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}; @@ -58,6 +58,7 @@ class ESP8266UartComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; + size_t available_for_write() override; UARTFlushResult flush() override; uint32_t get_config(); diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index e5d5fbc9839..5d90fc1c8a6 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -7,8 +7,10 @@ #include "esphome/core/log.h" #include "esphome/core/gpio.h" #include "driver/gpio.h" +#include "hal/uart_ll.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 +39,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 +98,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; @@ -135,11 +163,11 @@ void IDFUARTComponent::load_settings(bool dump_config) { } err = uart_driver_install(this->uart_num_, // UART number this->rx_buffer_size_, // RX ring buffer size - 0, // TX ring buffer size. If zero, driver will not use a TX buffer and TX function will - // block task until all data has been sent out - 0, // event queue size/depth - nullptr, // event queue - 0 // Flags used to allocate the interrupt + this->tx_buffer_size_, // TX ring buffer size; 0 makes uart_write_bytes() block until + // the FIFO has taken everything + 0, // event queue size/depth + nullptr, // event queue + 0 // Flags used to allocate the interrupt ); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_driver_install failed: %s", esp_err_to_name(err)); @@ -329,6 +357,9 @@ void IDFUARTComponent::dump_config() { " RX Timeout: %u", this->rx_buffer_size_, this->rx_full_threshold_, this->rx_timeout_); } + if (this->tx_buffer_size_ > 0) { + ESP_LOGCONFIG(TAG, " TX Buffer Size: %zu", this->tx_buffer_size_); + } if (this->flush_timeout_ms_ > 0) { ESP_LOGCONFIG(TAG, " Flush Timeout: %" PRIu32 " ms", this->flush_timeout_ms_); } @@ -336,12 +367,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(); } @@ -367,6 +400,18 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) { this->rx_timeout_ = rx_timeout; } +size_t IDFUARTComponent::available_for_write() { + if (this->uart_num_ == UART_NUM_MAX || !uart_is_driver_installed(this->uart_num_)) + return 0; + if (this->tx_buffer_size_ == 0) { + return uart_ll_get_txfifo_len(UART_LL_GET_HW(this->uart_num_)); + } + // The driver's figure already deducts its ring item headers, so a write of this size fits + size_t free = 0; + uart_get_tx_buffer_free_size(this->uart_num_, &free); + return free; +} + void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { int32_t write_len = uart_write_bytes(this->uart_num_, data, len); if (write_len != (int32_t) len) { diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index b591fbe9686..7c93b74fbdd 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -33,10 +33,17 @@ class IDFUARTComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; + size_t available_for_write() override; UARTFlushResult flush() override; void set_flush_timeout(uint32_t flush_timeout_ms) override { this->flush_timeout_ms_ = flush_timeout_ms; } + /// TX ring buffer size for the driver; 0 leaves TX unbuffered so write_array() blocks until + /// the FIFO has taken everything. + void set_tx_buffer_size(size_t tx_buffer_size) { this->tx_buffer_size_ = tx_buffer_size; } + + 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. @@ -105,7 +112,9 @@ class IDFUARTComponent final : public UARTComponent, public Component { bool has_peek_{false}; uint8_t peek_byte_{0}; - uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + 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). + size_t tx_buffer_size_{0}; #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/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 4eacd980db3..293cb9e786f 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -6,6 +6,8 @@ #include "esphome/core/log.h" #include "uart_component_libretiny.h" +#include "esphome/components/libretiny/core.h" + #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -118,6 +120,8 @@ void LibreTinyUARTComponent::setup() { #endif } + if (this->hardware_idx_ >= 0) + libretiny::ensure_serial_handle(this->hardware_idx_); this->serial_->begin(this->baud_rate_, get_config()); } diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py index 6c479f0cdd2..f97a7b2cb3e 100644 --- a/esphome/components/uart_mux/__init__.py +++ b/esphome/components/uart_mux/__init__.py @@ -20,9 +20,6 @@ ROUTE_LOCAL = "local" uart_mux_ns = cg.esphome_ns.namespace("uart_mux") UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) -SelectLocalAction = uart_mux_ns.class_("SelectLocalAction", automation.Action) -SelectBridgeAction = uart_mux_ns.class_("SelectBridgeAction", automation.Action) -IsLocalCondition = uart_mux_ns.class_("IsLocalCondition", automation.Condition) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -70,15 +67,16 @@ UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_simple_action( - "uart_mux.select_local", SelectLocalAction, UART_MUX_ACTION_SCHEMA, synchronous=True -) -automation.register_simple_action( - "uart_mux.select_bridge", - SelectBridgeAction, +automation.register_apply_action( + "uart_mux.select_local", UART_MUX_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("select_local()"), ) -automation.register_simple_condition( - "uart_mux.is_local", IsLocalCondition, UART_MUX_ACTION_SCHEMA +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.h b/esphome/components/uart_mux/uart_mux.h index 8e32a166434..5b9815a3caf 100644 --- a/esphome/components/uart_mux/uart_mux.h +++ b/esphome/components/uart_mux/uart_mux.h @@ -3,7 +3,6 @@ #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/automation.h" #include "esphome/core/component.h" namespace esphome::uart_mux { @@ -77,32 +76,5 @@ class UARTMux final : public uart::UARTComponent, public Component { bool load_settings_warned_{false}; }; -template class SelectLocalAction final : public Action { - public: - explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {} - void play(const Ts &...) override { this->parent_->select_local(); } - - protected: - UARTMux *parent_; -}; - -template class SelectBridgeAction final : public Action { - public: - explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {} - void play(const Ts &...) override { this->parent_->select_bridge(); } - - protected: - UARTMux *parent_; -}; - -template class IsLocalCondition final : public Condition { - public: - explicit IsLocalCondition(UARTMux *parent) : parent_(parent) {} - bool check(const Ts &...) override { return this->parent_->is_local(); } - - protected: - UARTMux *parent_; -}; - } // namespace esphome::uart_mux #endif diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py new file mode 100644 index 00000000000..6e7a6fef325 --- /dev/null +++ b/esphome/components/uart_tcp/__init__.py @@ -0,0 +1,161 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor, socket, uart +from esphome.components.const import ( + CONF_ALLOWED_IPS, + CONF_CONNECTED, + CONF_HOST, + CONF_RECONNECT_INTERVAL, + CONF_ROLE, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_DEBUG, + CONF_DUMMY_RECEIVER, + CONF_ID, + CONF_PORT, + CONF_UART_ID, + DEVICE_CLASS_CONNECTIVITY, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, +) +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@Bascht74"] +DOMAIN = "uart_tcp" +DEPENDENCIES = ["network", "uart"] +AUTO_LOAD = ["binary_sensor", "sensor", "socket"] +MULTI_CONF = True + +CONF_DISCONNECTS = "disconnects" + +uart_tcp_ns = cg.esphome_ns.namespace("uart_tcp") +UartTcp = uart_tcp_ns.class_("UartTcp", cg.Component, uart.UARTDevice) + + +BASE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(UartTcp), + cv.Required(CONF_UART_ID): cv.use_id(uart.UARTComponent), + cv.Required(CONF_PORT): cv.port, + cv.Optional( + CONF_RECONNECT_INTERVAL, default="5s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_DISCONNECTS): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "client": BASE_SCHEMA.extend({cv.Required(CONF_HOST): socket.ipv4_host}), + "server": BASE_SCHEMA.extend( + {cv.Optional(CONF_ALLOWED_IPS): socket.IPV4_ALLOW_SCHEMA} + ), + }, + key=CONF_ROLE, + default_type="client", + lower=True, + ), + socket.consume_role_sockets("uart_tcp"), +) + + +def _subtree_references_uart(node: object, uart_id: str) -> bool: + 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_dummy_receiver(uart_conf: ConfigType) -> ConfigType: + debug = uart_conf.get(CONF_DEBUG) + if isinstance(debug, dict) and debug.get(CONF_DUMMY_RECEIVER): + raise cv.Invalid( + "dummy_receiver reads this UART and drops the bytes uart_tcp should forward.", + [CONF_DEBUG, CONF_DUMMY_RECEIVER], + ) + return uart_conf + + +def _final_validate(config: ConfigType) -> ConfigType: + # A second reader would split the bytes with this one, and every connect + # discards what the other reader has not read yet. + full_config = fv.full_config.get() + data = full_config.data.setdefault(DOMAIN, {}) + uart_id = str(config[CONF_UART_ID]) + used = data.setdefault(CONF_UART_ID, set()) + if uart_id in used: + raise cv.Invalid( + f"The UART '{uart_id}' is already used by another 'uart_tcp' entry. " + "Each uart_tcp needs its own UART.", + [CONF_UART_ID], + ) + used.add(uart_id) + # Grouped CI builds share one bus between components, like uart's pin check. + # Bare `id:` references (a uart.write action) and lambdas are not caught. + if not CORE.testing_mode: + for domain, domain_conf in full_config.items(): + if domain != DOMAIN and _subtree_references_uart(domain_conf, uart_id): + raise cv.Invalid( + f"The UART '{uart_id}' is also used by '{domain}'. " + "uart_tcp requires exclusive use of that UART.", + [CONF_UART_ID], + ) + fv.id_declaration_match_schema(_reject_dummy_receiver)(config[CONF_UART_ID]) + + if config[CONF_ROLE] == "server": + # Two listeners on one port cannot both serve it. Only uart_tcp and + # tcp_uart servers are compared here, not other listeners such as api. + port = config[CONF_PORT] + ports = data.setdefault(CONF_PORT, set()) + if port in ports or any( + conf[CONF_ROLE] == "server" and conf[CONF_PORT] == port + for conf in full_config.get("tcp_uart", []) + ): + raise cv.Invalid( + f"Port {port} is already the listen port of another uart_tcp " + "server or of a tcp_uart server.", + [CONF_PORT], + ) + ports.add(port) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +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 config[CONF_ROLE] == "server": + socket.require_tcp_listener() + cg.add(var.set_server(True)) + socket.add_ipv4_allow( + var.set_allow, config.get(CONF_ALLOWED_IPS), config[CONF_ID] + ) + else: + socket.require_tcp_client_link() + cg.add(var.set_port(config[CONF_PORT])) + cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL])) + if (host := config.get(CONF_HOST)) is not None: + cg.add(var.set_host(host)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_CONNECTED, var.set_connected_sensor) + sensors = sensor.sub_sensors(config) + await sensors(CONF_DISCONNECTS, var.set_disconnects_sensor) diff --git a/esphome/components/uart_tcp/uart_tcp.cpp b/esphome/components/uart_tcp/uart_tcp.cpp new file mode 100644 index 00000000000..4f241f85671 --- /dev/null +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -0,0 +1,153 @@ +#include "uart_tcp.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::uart_tcp { + +static const char *const TAG = "uart_tcp"; + +// Keeps the pacing product in 32 bits up to about 10 Mbaud. +static constexpr uint32_t MAX_PACE_SPAN_MS = 4000; + +void UartTcp::setup() { + this->link_.begin(TAG); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.begin(TAG); +#endif + if (this->connected_sensor_ != nullptr) { + this->connected_sensor_->publish_state(false); + } +#ifdef USE_SENSOR + if (this->disconnects_sensor_ != nullptr) { + this->disconnects_sensor_->publish_state(0); + } +#endif +} + +void UartTcp::dump_config() { + ESP_LOGCONFIG(TAG, + "UART TCP:\n" + " %s: %s:%u\n" + " Reconnect Interval: %" PRIu32 "ms", + this->server_ ? LOG_STR_LITERAL("Listen") : LOG_STR_LITERAL("Host"), + this->server_ ? LOG_STR_LITERAL("*") : this->link_.host(), this->link_.port(), + this->link_.reconnect_interval()); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.dump_config(); +#endif + LOG_BINARY_SENSOR(" ", "Connected", this->connected_sensor_); +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Disconnects", this->disconnects_sensor_); +#endif +} + +void UartTcp::on_shutdown() { + this->link_.close(); +#ifdef USE_SOCKET_TCP_LISTENER + this->listener_.close(); +#endif +} + +void UartTcp::sync_link_() { + bool up = this->link_.connected(); + this->link_was_up_ = up; + if (up) { + // The driver kept whatever arrived while the link was down. + this->discard_uart_(); + } + if (this->connected_sensor_ != nullptr) { + this->connected_sensor_->publish_state(up); + } +#ifdef USE_SENSOR + // Only edges get here, so down is the falling edge. + if (!up && this->disconnects_sensor_ != nullptr) { + this->disconnects_++; + this->disconnects_sensor_->publish_state(this->disconnects_); + } +#endif +} + +void UartTcp::read_socket_() { + // A hardware write blocks until the driver takes every byte. Leave what does + // not fit in the socket, so TCP flow control throttles the peer. + size_t room = this->parent_->available_for_write(); + if (room == SIZE_MAX) { + // Capacity unknown on this platform; pace to the UART time since the last write, + // at most one loop interval and 4 s, so a pass woken early by the socket writes little. + uint32_t span = std::min( + {App.get_loop_component_start_time() - this->last_write_ms_, App.get_loop_interval(), MAX_PACE_SPAN_MS}); + // 10 bits per byte on the line. + uint32_t paced = this->parent_->get_baud_rate() / 10 * span / 1000; + room = std::max(1, paced); + } + if (room == 0) { + this->rx_pending_ = true; + return; + } + uint8_t tmp[READ_CHUNK]; + size_t want = std::min(room, sizeof(tmp)); + ssize_t count = this->link_.read(tmp, want); + if (count <= 0) { + // A dropped link (-1) is cleaned up by sync_link_() on the next loop. + if (count == 0) { + this->rx_pending_ = false; + } + return; + } + this->rx_pending_ = static_cast(count) == want; + this->write_array(tmp, static_cast(count)); + this->last_write_ms_ = App.get_loop_component_start_time(); +} + +void UartTcp::discard_uart_() { + // Drain exactly what was buffered while the link was down; later bytes are live. + uint8_t dump[DISCARD_CHUNK]; + size_t left = this->available(); + while (left != 0) { + size_t n = std::min(left, sizeof(dump)); + if (!this->read_array(dump, n)) { + return; + } + left -= n; + } +} + +void UartTcp::read_uart_() { + size_t want = std::min(this->available(), this->link_.tx_free()); + if (want != 0 && this->read_array(this->link_.tx_tail(), want)) { + this->link_.tx_commit(want); + } +} + +void UartTcp::loop() { +#ifdef USE_SOCKET_TCP_LISTENER + if (this->server_) { + // link_was_up_ holds the accept until the previous drop's edge has run, + // so the sensor and the stale UART discard always see the disconnect. + this->listener_.poll(this->link_, !this->link_was_up_); + } else { + this->link_.poll(); + } +#else + this->link_.poll(); +#endif + if (this->link_.connected() != this->link_was_up_) { + this->sync_link_(); + } + if (!this->link_was_up_) { + return; + } + if (this->rx_pending_ || this->link_.ready()) { + this->read_socket_(); + } + // UART bytes picked up here go out in the same pass. + this->read_uart_(); + this->link_.flush_tx(); +} + +} // namespace esphome::uart_tcp diff --git a/esphome/components/uart_tcp/uart_tcp.h b/esphome/components/uart_tcp/uart_tcp.h new file mode 100644 index 00000000000..256e059133a --- /dev/null +++ b/esphome/components/uart_tcp/uart_tcp.h @@ -0,0 +1,70 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#include "esphome/components/socket/tcp_client_link.h" +#ifdef USE_SOCKET_TCP_LISTENER +#include "esphome/components/socket/tcp_listener.h" +#endif +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" + +#include +#include + +namespace esphome::uart_tcp { + +/// Copies raw bytes between one hardware UART and one TCP socket. +class UartTcp final : public Component, public uart::UARTDevice { + public: + void set_host(const char *host) { this->link_.set_host(host); } + void set_port(uint16_t port) { this->link_.set_port(port); } + void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); } + void set_connected_sensor(binary_sensor::BinarySensor *sensor) { this->connected_sensor_ = sensor; } +#ifdef USE_SENSOR + void set_disconnects_sensor(sensor::Sensor *sensor) { this->disconnects_sensor_ = sensor; } +#endif +#ifdef USE_SOCKET_TCP_LISTENER + void set_server(bool server) { this->server_ = server; } +#ifdef USE_SOCKET_IPV4_ALLOW + void set_allow(const socket::Ipv4AllowEntry *entries, size_t count) { this->listener_.set_allow(entries, count); } +#endif +#endif + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + + protected: + void sync_link_(); + void read_socket_(); + void read_uart_(); + void discard_uart_(); + + static constexpr size_t READ_CHUNK = 128; + // Scratch size for dropping stale UART bytes on connect. + static constexpr size_t DISCARD_CHUNK = 32; + + socket::TcpClientLink link_; +#ifdef USE_SOCKET_TCP_LISTENER + socket::TcpListener listener_; +#endif + binary_sensor::BinarySensor *connected_sensor_{nullptr}; + // Loop start time of the last socket-to-UART write; sizes the next paced write. + uint32_t last_write_ms_{0}; +#ifdef USE_SENSOR + sensor::Sensor *disconnects_sensor_{nullptr}; + uint32_t disconnects_{0}; +#endif + bool server_{false}; + // The link state loop() saw last; edges clear the buffer and publish the sensor. + bool link_was_up_{false}; + // A read stopped before EAGAIN. ready() stays false until new data arrives. + bool rx_pending_{false}; +}; + +} // namespace esphome::uart_tcp diff --git a/esphome/components/udp/__init__.py b/esphome/components/udp/__init__.py index d96a731e9c0..316d4fc311e 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -124,7 +124,15 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_broadcast_port(conf_port[CONF_BROADCAST_PORT])) if (listen_address := str(config[CONF_LISTEN_ADDRESS])) != "255.255.255.255": cg.add(var.set_listen_address(listen_address)) - cg.add(var.set_addresses([str(addr) for addr in config[CONF_ADDRESSES]])) + # Shared flash table ended by nullptr, so the component stores only a pointer. + if addresses := [str(addr) for addr in config[CONF_ADDRESSES]]: + cg.add( + var.set_addresses( + cg.shared_progmem_array( + "udp_addresses", cg.const_char_ptr, [*addresses, cg.nullptr] + ) + ) + ) for conf in config.get(CONF_ON_RECEIVE, []): trigger_id = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) trigger = await automation.build_automation(trigger_id, trigger_argtype, conf) diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 858516c7462..904ed97dc0d 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -11,9 +11,15 @@ static const char *const TAG = "udp"; void UDPComponent::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - for (const auto &address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; 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 @@ -92,9 +98,14 @@ void UDPComponent::setup() { #endif #ifdef USE_SOCKET_IMPL_LWIP_TCP // 8266 and RP2040 `Duino - for (const auto &address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; 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_) @@ -129,7 +140,8 @@ void UDPComponent::dump_config() { " Listen Port: %u\n" " Broadcast Port: %u", this->listen_port_, this->broadcast_port_); - for (const char *address : this->addresses_) { + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; ESP_LOGCONFIG(TAG, " Address: %s", address); } if (this->listen_address_.has_value()) { diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 274e0119ee5..17e27fb7704 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -20,9 +20,8 @@ namespace esphome::udp { static const size_t MAX_PACKET_SIZE = 508; class UDPComponent final : public Component { public: - void set_addresses(std::initializer_list addresses) { this->addresses_ = addresses; } - /// Prevent accidental use of std::string which would dangle - void set_addresses(std::initializer_list addresses) = delete; + /// nullptr-terminated table of addresses; must outlive the component. + void set_addresses(const char *const *addresses) { this->addresses_ = addresses; } void set_listen_address(const char *listen_addr) { this->listen_address_ = network::IPAddress(listen_addr); } void set_listen_port(uint16_t port) { this->listen_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } @@ -52,7 +51,7 @@ class UDPComponent final : public Component { std::vector ipaddrs_{}; WiFiUDP udp_client_{}; #endif - FixedVector addresses_{}; + const char *const *addresses_{nullptr}; optional listen_address_{}; }; diff --git a/esphome/components/ufire_ec/sensor.py b/esphome/components/ufire_ec/sensor.py index 9d989ad4e67..6a632e05cd5 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( { @@ -72,13 +64,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_temperature_compensation(config[CONF_TEMPERATURE_COMPENSATION])) cg.add(var.set_temperature_coefficient(config[CONF_TEMPERATURE_COEFFICIENT])) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_EC in config: - sens = await sensor.new_sensor(config[CONF_EC]) - cg.add(var.set_ec_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_EC, var.set_ec_sensor) if CONF_TEMPERATURE_SENSOR in config: sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) @@ -96,25 +84,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 +101,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..6f205af784d 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( { @@ -67,13 +56,9 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if CONF_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_TEMPERATURE]) - cg.add(var.set_temperature_sensor(sens)) - - if CONF_PH in config: - sens = await sensor.new_sensor(config[CONF_PH]) - cg.add(var.set_ph_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_PH, var.set_ph_sensor) if CONF_TEMPERATURE_SENSOR in config: sens = await cg.get_variable(config[CONF_TEMPERATURE_SENSOR]) @@ -89,59 +74,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/unicode/__init__.py b/esphome/components/unicode/__init__.py new file mode 100644 index 00000000000..f70ffa95208 --- /dev/null +++ b/esphome/components/unicode/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@esphome/core"] diff --git a/esphome/components/unicode/unicode.cpp b/esphome/components/unicode/unicode.cpp new file mode 100644 index 00000000000..f5b4084fe09 --- /dev/null +++ b/esphome/components/unicode/unicode.cpp @@ -0,0 +1,97 @@ +#include "unicode.h" + +namespace esphome::unicode { + +uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { + // Safely cast to uint8_t* for correct bitwise operations on bytes + const uint8_t *current = reinterpret_cast(utf8_str); + uint32_t code_point = 0; + uint8_t c1 = *current++; + + // check for end of string + if (c1 == 0) { + *length = 0; + return 0; + } + + // --- 1-Byte Sequence: 0xxxxxxx (ASCII) --- + if (c1 < 0x80) { + // Valid ASCII byte. + code_point = c1; + // Optimization: No need to check for continuation bytes. + } + // --- 2-Byte Sequence: 110xxxxx 10xxxxxx --- + else if ((c1 & 0xE0) == 0xC0) { + uint8_t c2 = *current++; + + // Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx) + if ((c2 & 0xC0) != 0x80) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x1F) << 6; + code_point |= (c2 & 0x3F); + + // Error Check 2: Overlong check (2-byte must be > 0x7F) + if (code_point <= 0x7F) { + *length = 0; + return 0; + } + } + // --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF0) == 0xE0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x0F) << 12; + code_point |= (c2 & 0x3F) << 6; + code_point |= (c3 & 0x3F); + + // Error Check 2: Overlong check (3-byte must be > 0x7FF) + // Also check for surrogates (0xD800-0xDFFF) + if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) { + *length = 0; + return 0; + } + } + // --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF8) == 0xF0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + uint8_t c4 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x07) << 18; + code_point |= (c2 & 0x3F) << 12; + code_point |= (c3 & 0x3F) << 6; + code_point |= (c4 & 0x3F); + + // Error Check 2: Overlong check (4-byte must be > 0xFFFF) + // Also check for valid Unicode range (must be <= 0x10FFFF) + if (code_point <= 0xFFFF || code_point > 0x10FFFF) { + *length = 0; + return 0; + } + } + // --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) --- + else { + *length = 0; + return 0; + } + *length = current - reinterpret_cast(utf8_str); + return code_point; +} + +} // namespace esphome::unicode diff --git a/esphome/components/unicode/unicode.h b/esphome/components/unicode/unicode.h new file mode 100644 index 00000000000..9e69d036e0c --- /dev/null +++ b/esphome/components/unicode/unicode.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace esphome::unicode { + +/** + * Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string. + * If successful, return the codepoint and set the length to the number of bytes read. + * If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to + * 0. + * + * @param utf8_str The input string + * @param length Pointer to length storage + * @return The extracted code point + */ +uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length); + +} // namespace esphome::unicode 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/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/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 4abcc3a4499..94532958ed1 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,4 +1,7 @@ +from itertools import combinations + import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( VARIANT_ESP32H4, VARIANT_ESP32P4, @@ -15,6 +18,8 @@ from esphome.const import CONF_DEVICES, CONF_ID from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.cpp_types import Component +import esphome.final_validate as fv +from esphome.helpers import cpp_u16string_escape from esphome.types import ConfigType AUTO_LOAD = ["bytebuffer"] @@ -26,11 +31,21 @@ USBClient = usb_host_ns.class_("USBClient", Component) DOMAIN = "usb_host" CONF_VID = "vid" CONF_PID = "pid" +CONF_PRODUCT = "product" CONF_ENABLE_HUBS = "enable_hubs" CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests" CONF_MAX_PACKET_SIZE = "max_packet_size" +# VID/PID set to 0 or `None` product/manufacturer are wildcards +_FILTER_WILDCARDS = { + CONF_VID: 0, + CONF_PID: 0, + CONF_MANUFACTURER: None, + CONF_PRODUCT: None, +} + + def usb_device_schema( cls=USBClient, vid: int | None = None, pid: int | None = None ) -> cv.Schema: @@ -47,7 +62,40 @@ def usb_device_schema( schema = schema.extend({cv.Optional(CONF_PID, default=pid): cv.hex_uint16_t}) else: schema = schema.extend({cv.Required(CONF_PID): cv.hex_uint16_t}) - return schema + + return schema.extend( + { + cv.Optional(CONF_MANUFACTURER): cv.string_strict, + cv.Optional(CONF_PRODUCT): cv.string_strict, + } + ) + + +def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: + # Two entries overlap when no field they both constrain tells them apart + for first, second in combinations(configs, 2): + for key, wildcard in _FILTER_WILDCARDS.items(): + a = first.get(key) + b = second.get(key) + if wildcard not in (a, b) and a != b: + break + else: + raise cv.Invalid( + f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}" + ) + return configs + + +def _final_validate(config: ConfigType) -> ConfigType: + # Every USB client on the bus, whichever component configured it: any two could + # otherwise open the same device + clients = list(config.get(CONF_DEVICES) or ()) + clients.extend(fv.full_config.get().get("usb_uart") or ()) + validate_usb_clients(clients) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate def _set_max_packet_size(config: dict) -> dict: @@ -91,6 +139,15 @@ CONFIG_SCHEMA = cv.All( async def register_usb_client(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID], config[CONF_VID], config[CONF_PID]) await cg.register_component(var, config) + # UTF-16 literals, the encoding the descriptors use, so the device compares code units + if (manufacturer := config.get(CONF_MANUFACTURER)) is not None: + cg.add( + var.set_manufacturer_filter( + cg.RawExpression(cpp_u16string_escape(manufacturer)) + ) + ) + if (product := config.get(CONF_PRODUCT)) is not None: + cg.add(var.set_product_filter(cg.RawExpression(cpp_u16string_escape(product)))) return var diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 42869fb2a6d..aaab8e22a18 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -5,6 +5,7 @@ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/defines.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include #include "usb/usb_host.h" #include @@ -12,6 +13,7 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/core/event_pool.h" #include +#include namespace esphome::usb_host { @@ -117,6 +119,25 @@ struct UsbEvent { // callback function type. +// USB string descriptors hold at most 126 characters; one more for the terminator +static constexpr size_t DESC_STRING_BUF_SIZE = 128; + +/// Identity of a connected USB device, copied out of the descriptors the USB host +/// stack caches for the lifetime of the connection +struct UsbDeviceInfo { + uint16_t vendor_id; + uint16_t product_id; + uint16_t bcd_device; + char manufacturer[DESC_STRING_BUF_SIZE]; + char product[DESC_STRING_BUF_SIZE]; + char serial_number[DESC_STRING_BUF_SIZE]; +}; + +/// Copy a USB string descriptor into a NUL-terminated buffer. A missing descriptor copies as +/// an empty string. Returns false when a descriptor contains non-ASCII characters, +/// UTF-16 to UTF-8 conversion is not currently implemented. +bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer); + enum ClientState { USB_CLIENT_INIT = 0, USB_CLIENT_OPEN, @@ -143,6 +164,25 @@ class USBClient : public Component { trq_bitmask_t get_trq_in_use() const { return trq_in_use_; } bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, const std::vector &data = {}); + void set_manufacturer_filter(const char16_t *manufacturer) { this->manufacturer_filter_ = manufacturer; } + void set_product_filter(const char16_t *product) { this->product_filter_ = product; } + + /// Whether a device has been opened and its setup by the subclass has finished + bool is_connected() const { return this->connection_reported_; } + + /// Copy the connected device's identity out of the cached USB descriptors. + /// Returns false when no device is connected or the host stack refused the query. + bool get_device_info(UsbDeviceInfo &info) const; + + /// Register a callback for the device this client claims being connected (true) or + /// removed (false). Fires only for a device that was fully opened, so a device another + /// client claims is never reported. Called from the main loop: connected once the device + /// has been enumerated and the subclass has finished its setup of it (whether or not that + /// setup succeeded), removed after on_disconnected() has run. This tracks the device's + /// presence, not whether a given channel is usable. + template void add_on_connection_callback(F &&callback) { + this->connection_callback_.add(std::forward(callback)); + } // Lock-free event queue and pool for USB task to main loop communication // Must be public for access from static callbacks @@ -161,6 +201,13 @@ class USBClient : public Component { TransferRequest *get_trq_(); // Lock-free allocation using atomic bitmask (multi-consumer safe) virtual void disconnect(); virtual void on_connected() {} + + /// Whether the subclass reports the device as connected itself, once its own setup of + /// the device has finished, rather than as soon as the device has been opened + virtual bool reports_connection_itself() const { return false; } + /// Report the claimed device to the connection callbacks. Idempotent; a subclass that + /// reports itself calls this once the device is ready to use. + void report_connected_(); virtual void on_disconnected() { // Reset all requests to available (all bits to 0) this->trq_in_use_.store(0); @@ -177,12 +224,18 @@ class USBClient : public Component { usb_device_handle_t device_handle_{}; int device_addr_{-1}; int state_{USB_CLIENT_INIT}; + LazyCallbackManager connection_callback_; // Lock-free pool management using atomic bitmask (no dynamic allocation) // Bit i = 1: requests_[i] is in use, Bit i = 0: requests_[i] is available // Supports multiple concurrent consumers and producers (both threads can allocate/deallocate) std::atomic trq_in_use_; + const char16_t *manufacturer_filter_{nullptr}; + const char16_t *product_filter_{nullptr}; uint16_t vid_{}; uint16_t pid_{}; + // Whether the connection callbacks were told about the current device, so a removal is + // only ever reported for a device that was reported connected + bool connection_reported_{false}; }; class USBHost final : public Component { public: diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 7bc2b0a16b7..1b50aa75baf 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -11,6 +11,8 @@ #include #include #include +#include + namespace esphome::usb_host { #pragma GCC diagnostic ignored "-Wparentheses" @@ -143,25 +145,92 @@ static void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc } while (next_desc != NULL); } #endif -// USB string descriptors: bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType). -// Character count = (bLength - 2) / 2, max 126 chars + null terminator. -static constexpr size_t DESC_STRING_BUF_SIZE = 128; - -static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { +// bLength (uint8_t, max 255) includes the 2-byte header (bLength and bDescriptorType), +// so character count = (bLength - 2) / 2. +bool copy_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { + buffer[0] = '\0'; if (desc == nullptr || desc->bLength < 2) - return "(unspecified)"; + return true; int char_count = (desc->bLength - 2) / 2; char *p = buffer.data(); char *end = p + buffer.size() - 1; for (int i = 0; i != char_count && p < end; i++) { auto c = desc->wData[i]; - if (c < 0x100) - *p++ = static_cast(c); + // TODO: encode non-ASCII code units as UTF-8 if a device with such descriptors turns up + if (c >= 0x80) { + buffer[0] = '\0'; + return false; + } + *p++ = static_cast(c); + } + *p = '\0'; + return true; +} + +// Folds UTF-16 to Latin-1 for logging, dropping anything that does not fit +template +static const char *utf16_to_latin1(const T *data, size_t count, std::span buffer) { + char *p = buffer.data(); + char *end = p + buffer.size() - 1; + for (size_t i = 0; i != count && p < end; i++) { + if (data[i] < 0x100) + *p++ = static_cast(data[i]); } *p = '\0'; return buffer.data(); } +static const char *get_descriptor_string(const usb_str_desc_t *desc, std::span buffer) { + if (desc == nullptr || desc->bLength < 2) + return "(unspecified)"; + return utf16_to_latin1(desc->wData, (desc->bLength - 2) / 2, buffer); +} + +static const char *filter_string(const char16_t *filter, std::span buffer) { + return utf16_to_latin1(filter, std::char_traits::length(filter), buffer); +} + +// Both sides are UTF-16: the descriptor by specification, the filter because code +// generation emits it as a u"" literal +static bool descriptor_string_equals(const usb_str_desc_t *desc, const char16_t *expected) { + const int char_count = (desc == nullptr || desc->bLength < 2) ? 0 : (desc->bLength - 2) / 2; + for (int i = 0; i != char_count; i++) { + if (expected[i] == u'\0' || desc->wData[i] != expected[i]) + return false; + } + return expected[char_count] == u'\0'; +} + +bool USBClient::get_device_info(UsbDeviceInfo &info) const { + if (!this->is_connected()) + return false; + const usb_device_desc_t *desc; + esp_err_t err = usb_host_get_device_descriptor(this->device_handle_, &desc); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Device descriptor query failed: %s", esp_err_to_name(err)); + return false; + } + info.vendor_id = desc->idVendor; + info.product_id = desc->idProduct; + info.bcd_device = desc->bcdDevice; + usb_device_info_t dev_info; + err = usb_host_device_info(this->device_handle_, &dev_info); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Device info query failed: %s", esp_err_to_name(err)); + return false; + } + if (!copy_descriptor_string(dev_info.str_desc_manufacturer, info.manufacturer)) { + ESP_LOGW(TAG, "Manufacturer string descriptor is not ASCII"); + } + if (!copy_descriptor_string(dev_info.str_desc_product, info.product)) { + ESP_LOGW(TAG, "Product string descriptor is not ASCII"); + } + if (!copy_descriptor_string(dev_info.str_desc_serial_num, info.serial_number)) { + ESP_LOGW(TAG, "Serial number string descriptor is not ASCII"); + } + return true; +} + // CALLBACK CONTEXT: USB task (called from usb_host_client_handle_events in USB task) static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) { auto *client = static_cast(ptr); @@ -302,12 +371,10 @@ void USBClient::handle_open_state_() { return; } ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct); - if (desc->idVendor != this->vid_ || desc->idProduct != this->pid_) { - if (this->vid_ != 0 || this->pid_ != 0) { - ESP_LOGD(TAG, "Not our device, closing"); - this->disconnect(); - return; - } + if ((this->vid_ != 0 && desc->idVendor != this->vid_) || (this->pid_ != 0 && desc->idProduct != this->pid_)) { + ESP_LOGD(TAG, "Not our device, closing"); + this->disconnect(); + return; } usb_device_info_t dev_info; err = usb_host_device_info(this->device_handle_, &dev_info); @@ -316,9 +383,21 @@ void USBClient::handle_open_state_() { this->disconnect(); return; } - this->state_ = USB_CLIENT_CONNECTED; char buf_manuf[DESC_STRING_BUF_SIZE]; char buf_product[DESC_STRING_BUF_SIZE]; + const bool manufacturer_matches = + this->manufacturer_filter_ == nullptr || + descriptor_string_equals(dev_info.str_desc_manufacturer, this->manufacturer_filter_); + const bool product_matches = + this->product_filter_ == nullptr || descriptor_string_equals(dev_info.str_desc_product, this->product_filter_); + if (!manufacturer_matches || !product_matches) { + ESP_LOGD(TAG, "Device does not match filter, closing. Manuf: %s; Prod: %s", + get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf), + get_descriptor_string(dev_info.str_desc_product, buf_product)); + this->disconnect(); + return; + } + this->state_ = USB_CLIENT_CONNECTED; char buf_serial[DESC_STRING_BUF_SIZE]; ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s", get_descriptor_string(dev_info.str_desc_manufacturer, buf_manuf), @@ -336,6 +415,18 @@ void USBClient::handle_open_state_() { usb_client_print_config_descriptor(config_desc, nullptr); #endif this->on_connected(); + // on_connected() may have rejected the device (no usable interface, say) and closed it + if (this->state_ == USB_CLIENT_CONNECTED && !this->reports_connection_itself()) { + this->report_connected_(); + } +} + +void USBClient::report_connected_() { + if (this->state_ != USB_CLIENT_CONNECTED || this->connection_reported_) { + return; + } + this->connection_reported_ = true; + this->connection_callback_.call(true); } void USBClient::on_opened(uint8_t addr) { @@ -406,6 +497,10 @@ TransferRequest *USBClient::get_trq_() { } void USBClient::disconnect() { + // Also reached for a device this client opened and then declined, or lost before it was + // ready; neither was reported as connected, so neither is reported as removed + const bool was_reported = this->connection_reported_; + this->connection_reported_ = false; this->on_disconnected(); auto err = usb_host_device_close(this->handle_, this->device_handle_); if (err != ESP_OK) { @@ -414,6 +509,9 @@ void USBClient::disconnect() { this->state_ = USB_CLIENT_INIT; this->device_handle_ = nullptr; this->device_addr_ = -1; + if (was_reported) { + this->connection_callback_.call(false); + } } // THREAD CONTEXT: Called from main loop thread only @@ -557,6 +655,13 @@ void USBClient::dump_config() { " Vendor id %04X\n" " Product id %04X", this->vid_, this->pid_); + char buf[DESC_STRING_BUF_SIZE]; + if (this->manufacturer_filter_ != nullptr) { + ESP_LOGCONFIG(TAG, " Manufacturer %s", filter_string(this->manufacturer_filter_, buf)); + } + if (this->product_filter_ != nullptr) { + ESP_LOGCONFIG(TAG, " Product %s", filter_string(this->product_filter_, buf)); + } } // THREAD CONTEXT: Called from both USB task and main loop threads // - USB task: Immediately after transfer callback completes diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index 5d0f8be1655..a632b4e38d0 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -7,6 +7,7 @@ from esphome.components.usb_host import ( register_usb_client, usb_device_schema, ) +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_BAUD_RATE, @@ -15,8 +16,9 @@ from esphome.const import ( CONF_DEBUG, CONF_DUMMY_RECEIVER, CONF_ID, + CONF_TYPE, ) -from esphome.core import CORE +from esphome.core import CORE, ID from esphome.cpp_types import Component from esphome.types import ConfigType @@ -27,6 +29,15 @@ usb_uart_ns = cg.esphome_ns.namespace("usb_uart") USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) + +def is_usb_uart_channel(uart_id: ID, full_config: ConfigType) -> bool: + return any( + channel[CONF_ID] == uart_id + for device in full_config.get("usb_uart") or [] + for channel in device[CONF_CHANNELS] + ) + + UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, @@ -64,6 +75,8 @@ class Type: self.vid = vid self.pid = pid self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) + # CDC ACM lives in usb_uart.cpp; each vendor driver has its own .cpp + self.driver = None if cls == "CdcAcm" else cls self._max_channels = max_channels self.baud_rate_required = baud_rate_required self.max_baud = max_baud @@ -121,6 +134,21 @@ uart_types = ( ), ) +_TYPES_BY_NAME = {it.name: it for it in uart_types} + + +def _driver_define(driver: str) -> str: + return f"USE_USB_UART_{driver}" + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + f"{it.driver.lower()}.cpp": _driver_define(it.driver) + for it in uart_types + if it.driver is not None + } +) + def channel_schema(type_: "Type") -> cv.Schema: schema = cv.Schema( @@ -211,6 +239,8 @@ async def to_code(config: list[ConfigType]) -> None: cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count) for device in config: + if (driver := _TYPES_BY_NAME[device[CONF_TYPE]].driver) is not None: + cg.add_define(_driver_define(driver)) var = await register_usb_client(device) # The C++ default is true; only emit the override if not device.get(CONF_CLAIM_COMM_INTERFACE, True): diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3113f695f60..86dfa6d729f 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -21,8 +21,6 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in int conf_offset, ep_offset; // look for an interface with an interrupt endpoint (notify), and one with two bulk endpoints (data in/out) CdcEps eps{}; - eps.bulk_interface_number = 0xFF; - eps.interrupt_interface_number = 0xFF; for (;;) { const auto *intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx++, 0, &conf_offset); if (!intf_desc) { @@ -463,10 +461,12 @@ void USBUartTypeCdcAcm::on_connected() { void USBUartTypeCdcAcm::on_disconnected() { for (auto *channel : this->channels_) { - if (channel->cdc_dev_.in_ep != nullptr) { - usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); - usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); - } + // Not set up for this device: it was rejected before on_connected() ran, or it has + // fewer ports than there are channels. Nothing was claimed for it. + if (channel->cdc_dev_.in_ep == nullptr) + continue; + usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); + usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); if (channel->cdc_dev_.out_ep != nullptr) { usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); @@ -494,6 +494,8 @@ void USBUartTypeCdcAcm::on_disconnected() { } } channel->initialised_.store(false); + // The descriptors these point into are freed with the device + channel->cdc_dev_ = {}; } USBClient::on_disconnected(); } @@ -660,6 +662,8 @@ bool USBUartComponent::run_config_machine_() { this->cfg_single_ = nullptr; } else if (++this->cfg_channel_idx_ >= this->channels_.size()) { this->cfg_active_ = false; + // Init is done and the line settings are on the wire: now the device is ready to use + this->report_connected_(); } // If the machine just went idle and a reload was requested while it was busy, start it now. diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 22563209da3..c6ae0b4eef5 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -33,10 +33,11 @@ struct CdcEps { const usb_ep_desc_t *notify_ep; const usb_ep_desc_t *in_ep; const usb_ep_desc_t *out_ep; - uint8_t bulk_interface_number; + // 0xFF marks a channel that was never matched to a CDC function on the device + uint8_t bulk_interface_number{0xFF}; // Also the wIndex target for CDC class requests (SET_LINE_CODING etc.), so it // must remain valid even when the interface itself is not claimed. - uint8_t interrupt_interface_number; + uint8_t interrupt_interface_number{0xFF}; bool interrupt_interface_claimed{false}; }; @@ -155,7 +156,22 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; } + void set_parity(UARTParityOptions parity) { + this->parity_ = parity; + // Keep the base-class parity in sync so uart::UARTComponent::get_parity() reports the configured value. + // MARK/SPACE have no uart:: equivalent and report as NONE. + switch (parity) { + case UART_CONFIG_PARITY_EVEN: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_EVEN); + break; + case UART_CONFIG_PARITY_ODD: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_ODD); + break; + default: + uart::UARTComponent::set_parity(uart::UART_CONFIG_PARITY_NONE); + break; + } + } void set_debug(bool debug) { this->debug_ = debug; } void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } void set_debug_prefix(const char *prefix) { this->debug_prefix_ = StringRef(prefix); } @@ -167,6 +183,13 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented cb) { this->rx_callback_ = std::move(cb); } + /// USB interface number a host driver binds to for this channel: the communication + /// interface of a CDC ACM function, otherwise the data interface. + uint8_t get_interface_number() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_number + : this->cdc_dev_.bulk_interface_number; + } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -252,6 +275,10 @@ class USBUartComponent : public usb_host::USBClient { // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + // The device is only usable once the config machine has applied every channel's line + // settings, so the connected report waits for run_config_machine_() to finish the init + bool reports_connection_itself() const override { return true; } + std::vector channels_{}; // Config state machine 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/text_sensor.py b/esphome/components/version/text_sensor.py index cad239dc415..189d18256f1 100644 --- a/esphome/components/version/text_sensor.py +++ b/esphome/components/version/text_sensor.py @@ -35,5 +35,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: var = await text_sensor.new_text_sensor(config) await cg.register_component(var, config) - cg.add(var.set_hide_hash(config[CONF_HIDE_HASH])) - cg.add(var.set_hide_timestamp(config[CONF_HIDE_TIMESTAMP])) + # Both flags default to false in C++, so only true needs a setter. + if config[CONF_HIDE_HASH]: + cg.add(var.set_hide_hash(True)) + if config[CONF_HIDE_TIMESTAMP]: + cg.add(var.set_hide_timestamp(True)) diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index 7ff6ac4d352..85374350790 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -15,8 +15,8 @@ class VersionTextSensor final : public text_sensor::TextSensor, public Component void dump_config() override; protected: - bool hide_hash_{false}; - bool hide_timestamp_{false}; + bool hide_hash_{false}; // Must match the setter check in text_sensor.py + bool hide_timestamp_{false}; // Must match the setter check in text_sensor.py }; } // namespace esphome::version 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..ae5eee3e227 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -44,7 +44,7 @@ enum VoiceAssistantFeature : uint32_t { FEATURE_MULTI_CHANNEL_AUDIO = 1 << 6, }; -enum class State { +enum class State : uint8_t { IDLE, START_MICROPHONE, STARTING_MICROPHONE, @@ -96,7 +96,7 @@ struct Configuration { }; #ifdef USE_MEDIA_PLAYER -enum class MediaPlayerResponseState { +enum class MediaPlayerResponseState : uint8_t { IDLE, URL_SENT, PLAYING, @@ -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; } @@ -235,6 +237,7 @@ class VoiceAssistant final : public Component { void set_state_(State state); void set_state_(State state, State desired_state); + bool start_udp_socket_(); void signal_stop_(); void start_playback_timeout_(); @@ -278,8 +281,6 @@ class VoiceAssistant final : public Component { Trigger timer_updated_trigger_; Trigger timer_cancelled_trigger_; Trigger &> timer_tick_trigger_; - bool has_timers_{false}; - bool timer_tick_running_{false}; microphone::MicrophoneSource *mic_source_{nullptr}; microphone::MicrophoneSource *mic_source2_{nullptr}; @@ -290,19 +291,12 @@ class VoiceAssistant final : public Component { size_t speaker_buffer_index_{0}; size_t speaker_buffer_size_{0}; size_t speaker_bytes_received_{0}; - bool wait_for_stream_end_{false}; - bool stream_ended_{false}; #endif #ifdef USE_MEDIA_PLAYER media_player::MediaPlayer *media_player_{nullptr}; std::string tts_response_url_; - bool started_streaming_tts_{false}; - - MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE}; #endif - bool local_output_{false}; - std::string conversation_id_; std::string wake_word_; @@ -322,64 +316,37 @@ class VoiceAssistant final : public Component { // prolonged one can be detected and stopped; 0 means no imbalance is currently being timed. uint32_t audio_channel_stall_start_{0}; - bool use_wake_word_; - uint8_t noise_suppression_level_; - uint8_t auto_gain_; float volume_multiplier_; uint32_t conversation_timeout_; - bool continuous_{false}; - bool silence_detection_; - - bool continue_conversation_{false}; - - State state_{State::IDLE}; - State desired_state_{State::IDLE}; - - AudioMode audio_mode_{AUDIO_MODE_UDP}; - bool udp_socket_running_{false}; - bool start_udp_socket_(); - Configuration config_{}; #ifdef USE_MICRO_WAKE_WORD micro_wake_word::MicroWakeWord *micro_wake_word_{nullptr}; #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: + // 1 byte members grouped at the end so they share padding instead of each taking a word + State state_{State::IDLE}; + State desired_state_{State::IDLE}; + AudioMode audio_mode_{AUDIO_MODE_UDP}; + bool has_timers_{false}; + bool timer_tick_running_{false}; +#ifdef USE_SPEAKER + bool wait_for_stream_end_{false}; + bool stream_ended_{false}; +#endif +#ifdef USE_MEDIA_PLAYER + MediaPlayerResponseState media_player_response_state_{MediaPlayerResponseState::IDLE}; + bool started_streaming_tts_{false}; +#endif + bool local_output_{false}; + bool use_wake_word_; + uint8_t noise_suppression_level_; + uint8_t auto_gain_; + bool continuous_{false}; 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; } + bool continue_conversation_{false}; + bool udp_socket_running_{false}; }; extern VoiceAssistant *global_voice_assistant; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) 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/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2459163786d..6bc4d7ab5c9 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 @@ -62,7 +63,7 @@ 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 = {} @@ -378,8 +379,7 @@ 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] @@ -388,6 +388,9 @@ async def to_code(config: ConfigType) -> None: cg.add(paren.set_port(port)) cg.add_define("USE_WEBSERVER") 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. @@ -410,7 +413,16 @@ async def to_code(config: ConfigType) -> None: cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS") if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None: cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS") - cg.add(var.set_allowed_origins(allowed_origins)) + # Shared flash table ended by nullptr, so the server stores only a pointer. + cg.add( + var.set_allowed_origins( + cg.shared_progmem_array( + "web_server_allowed_origins", + cg.const_char_ptr, + [*allowed_origins, cg.nullptr], + ) + ) + ) if (auth := config.get(CONF_AUTH)) is not None: cg.add_define("USE_WEBSERVER_AUTH") # The scheme is fixed at build time so the unused Basic/Digest code path is compiled diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 8b0dfe166fb..fde86502d4a 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 @@ -194,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 @@ -251,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 { @@ -365,7 +376,6 @@ json::SerializationBuffer<> WebServer::get_config_json() { } void WebServer::setup() { - ControllerRegistry::register_controller(this); this->base_->init(); #ifdef USE_LOGGER @@ -407,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 @@ -484,7 +495,8 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const #ifdef USE_WEBSERVER_ALLOWED_ORIGINS // Otherwise the origin must be explicitly allowed via configuration. - for (const char *allowed_origin : this->allowed_origins_) { + for (const char *const *it = this->allowed_origins_; *it != nullptr; it++) { + const char *allowed_origin = *it; // A single "*" entry allows any origin. if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') return true; @@ -636,21 +648,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(); @@ -664,8 +676,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 @@ -683,32 +693,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 @@ -744,8 +752,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; } @@ -769,14 +778,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); @@ -784,8 +792,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 @@ -797,8 +803,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); @@ -810,19 +817,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 @@ -840,32 +844,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 @@ -883,8 +885,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); @@ -924,14 +927,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); @@ -945,8 +947,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 @@ -964,8 +964,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); @@ -1005,17 +1006,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) { @@ -1026,8 +1026,6 @@ json::SerializationBuffer<> WebServer::light_json_(light::LightState *obj, JsonD } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1045,8 +1043,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; } @@ -1093,14 +1092,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, @@ -1115,8 +1113,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 @@ -1134,8 +1130,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"))) { @@ -1153,14 +1150,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(); @@ -1183,8 +1179,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 @@ -1201,8 +1195,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"))) { @@ -1227,14 +1222,13 @@ 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) @@ -1244,8 +1238,6 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATE @@ -1262,8 +1254,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"))) { @@ -1287,14 +1280,13 @@ 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) @@ -1304,8 +1296,6 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_TIME @@ -1322,8 +1312,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"))) { @@ -1347,14 +1338,13 @@ 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) @@ -1365,8 +1355,6 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity * if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATETIME @@ -1384,8 +1372,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"))) { @@ -1405,18 +1394,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(); @@ -1424,8 +1416,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 @@ -1443,8 +1433,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; } @@ -1464,16 +1455,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()) @@ -1485,8 +1476,6 @@ json::SerializationBuffer<> WebServer::select_json_(select::Select *obj, StringR } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1504,8 +1493,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; } @@ -1546,17 +1536,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(); @@ -1599,6 +1588,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); } @@ -1654,7 +1644,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 @@ -1691,8 +1680,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; } @@ -1716,22 +1706,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 @@ -1749,8 +1737,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; } @@ -1795,14 +1784,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, @@ -1814,8 +1802,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 @@ -1833,8 +1819,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; } @@ -1876,20 +1863,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", @@ -1897,8 +1885,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 @@ -1916,8 +1902,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"))) { @@ -1955,15 +1942,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(); @@ -1980,6 +1967,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); } @@ -2009,8 +1997,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 @@ -2023,8 +2009,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"))) { @@ -2083,27 +2070,22 @@ 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); - auto traits = obj->get_traits(); - - root[ESPHOME_F("supports_transmitter")] = traits.get_supports_transmitter(); - root[ESPHOME_F("supports_receiver")] = traits.get_supports_receiver(); + root[ESPHOME_F("supports_transmitter")] = obj->get_supports_transmitter(); + root[ESPHOME_F("supports_receiver")] = obj->get_supports_receiver(); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -2116,8 +2098,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"))) { @@ -2175,14 +2158,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); @@ -2200,8 +2182,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 @@ -2221,8 +2201,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; } } @@ -2231,17 +2212,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); @@ -2257,8 +2238,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 @@ -2275,8 +2254,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; } @@ -2291,17 +2271,16 @@ 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)), @@ -2318,7 +2297,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 7aa4ac24a3b..1f9b6cb9348 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::list origins) { this->allowed_origins_ = origins; } + void set_allowed_origins(const char *const *origins) { this->allowed_origins_ = origins; } #endif // ========== INTERNAL METHODS ========== @@ -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. @@ -525,6 +531,9 @@ class WebServer final : public Controller, public Component, public AsyncWebHand bool include_internal_{false}; protected: + // Declared here so it packs into the padding after include_internal_ + bool expose_log_{true}; + void add_sorting_info_(JsonObject &root, EntityBase *entity); #ifdef USE_LIGHT @@ -608,11 +617,10 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #ifdef USE_WEBSERVER_JS_INCLUDE const char *js_include_{nullptr}; #endif - bool expose_log_{true}; #ifdef USE_WEBSERVER_ALLOWED_ORIGINS // Extra origins allowed to make cross-origin browser requests ("*" means any origin). // Only compiled when allowed_origins is configured; same-origin is always allowed regardless. - FixedVector allowed_origins_; + const char *const *allowed_origins_{nullptr}; #endif /// Check whether the given request Origin is permitted. Same-origin (matching the Host the @@ -622,77 +630,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_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 5a400dfbf3e..b9d079b53fb 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,6 +1,6 @@ from esphome.components.esp32 import ( - add_idf_sdkconfig_option, include_builtin_idf_component, + set_idf_sdkconfig_default, ) from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv @@ -16,10 +16,9 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: # Increase the maximum supported size of headers section in HTTP request packet to be processed by the server - add_idf_sdkconfig_option("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) - # Re-enable esp-tls (excluded by default to save compile time); - # web_server_idf.cpp includes for digest auth - include_builtin_idf_component("esp-tls") + set_idf_sdkconfig_default("CONFIG_HTTPD_MAX_REQ_HDR_LEN", 1024) + # Re-enable ESP-IDF's HTTP server (excluded by default to save compile time). + # Basic auth uses mbedtls_base64_encode directly, so no TLS stack is needed. include_builtin_idf_component("esp_http_server") 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..a35ad652141 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -1,19 +1,23 @@ #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" -#include "esp_tls_crypto.h" +#include #include #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; @@ -544,14 +554,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw constexpr size_t max_digest_len = 350; char digest[max_digest_len]; size_t out; - esp_crypto_base64_encode(reinterpret_cast(digest), max_digest_len, &out, - reinterpret_cast(user_info), user_info_len); + // The buffer bound above makes failure unreachable; reject rather than + // compare against an unwritten digest if that ever changes. + if (mbedtls_base64_encode(reinterpret_cast(digest), max_digest_len, &out, + reinterpret_cast(user_info), user_info_len) != 0) { + return false; + } // Constant-time comparison to avoid timing side channels. // No early return on length mismatch — the length difference is folded // into the accumulator so any mismatch is rejected. const char *provided = auth_str + auth_prefix_len; - size_t digest_len = out; // length from esp_crypto_base64_encode + size_t digest_len = out; // Derive provided_len from the already-sized std::string rather than // rescanning with strlen (avoids attacker-controlled scan length). size_t provided_len = auth.value().size() - auth_prefix_len; @@ -728,7 +742,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 +753,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_() { @@ -750,18 +765,21 @@ void AsyncEventSource::adopt_pending_sessions_main_loop_() { this->has_pending_sessions_.store(false, std::memory_order_relaxed); } for (auto *rsp : incoming) { - // Already disconnected? Drop it; skip on_connect_/session start on a dead session. - if (rsp->fd_.load() == 0) { + // Already disconnected? Drop it; skip session start on a dead session. + if (rsp->safe_to_delete_()) { delete rsp; // NOLINT(cppcoreguidelines-owning-memory) 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. - rsp->start_session_main_loop_(); - if (this->on_connect_) { - this->on_connect_(rsp); + // 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); + rsp->start_session_main_loop_(); } } // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) @@ -791,7 +809,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 +837,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 +855,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 +893,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 +910,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 +1252,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 743d296d73a..581ba8323b5 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,17 +342,43 @@ 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; class AsyncEventSource : public AsyncWebHandler { friend class AsyncEventSourceResponse; - using connect_handler_t = std::function; public: AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} @@ -334,11 +393,6 @@ class AsyncEventSource : public AsyncWebHandler { } // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override; - // Callback runs on the main loop (not the httpd task) after the session's - // initial ping/config/sorting_groups have been sent. - // NOLINTNEXTLINE(readability-identifier-naming) - void onConnect(connect_handler_t &&cb) { this->on_connect_ = std::move(cb); } - void try_send_nodefer(const char *message, size_t message_len, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); @@ -358,7 +412,6 @@ class AsyncEventSource : public AsyncWebHandler { // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. std::vector pending_sessions_; Mutex pending_mutex_; - connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; std::atomic has_pending_sessions_{false}; }; diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index c22d49e6654..f357bf75b35 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, require_mbedtls_tls_extras, ) from esphome.components.network import ( @@ -67,14 +68,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 @@ -369,6 +369,13 @@ def final_validate(config): "The AP will not be usable for configuration or monitoring. " "Add 'captive_portal:' or 'web_server:' to your configuration." ) + if "esp32_hosted" in full_config and any( + CONF_EAP in net for net in config.get(CONF_NETWORKS, []) + ): + _LOGGER.warning( + "WPA2 Enterprise ('eap:') is not supported by the esp32_hosted coprocessor " + "firmware ESPHome provides" + ) def _consume_wifi_sockets(config: ConfigType) -> ConfigType: @@ -508,7 +515,7 @@ CONFIG_SCHEMA = cv.All( rp2="light", bk72xx="none", rtl87xx="none", - ln882x="light", + ln882x="none", ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): _fast_connect_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, @@ -663,6 +670,10 @@ async def to_code(config): 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") + # The LN882H radio cannot run the AP and STA together; the fallback AP + # takes turns with the networks instead. + if CORE.is_ln882x: + cg.add_define("USE_WIFI_AP_EXCLUSIVE") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which # drops SoftAP support / the LWIP DHCP server when AP mode is unused. @@ -673,10 +684,10 @@ async def to_code(config): 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. + # The supplicant's Kconfig select cannot override the IDF 5 TLS + # role choice; the EAP client talks to mbedTLS directly (no + # esp-tls) and needs every trimmed extra. + require_mbedtls_tls() require_mbedtls_tls_extras() # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP @@ -728,6 +739,10 @@ async def to_code(config): if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) + # LN882x: hand the SDK the BSSID LibreTiny 1.13 drops (see wifi_component_libretiny.cpp); remove once fixed upstream. + if CORE.is_ln882x: + cg.add_build_flag("-Wl,--wrap=wifi_sta_connect") + # post_connect_roaming defaults to true in C++ - disable if user disabled it # or if 802.11k/v is enabled (driver handles roaming natively) if ( @@ -772,7 +787,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) @@ -830,45 +845,49 @@ 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" diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 125139ad163..fd982c0d312 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -380,6 +380,25 @@ static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; /// Cooldown duration in milliseconds after adapter restart or repeated failures /// Allows WiFi hardware to stabilize before next connection attempt static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; +#ifdef USE_WIFI_AP +/// Whether the radio runs the fallback AP on its own, without STA alongside. +#ifdef USE_WIFI_AP_EXCLUSIVE +static constexpr bool WIFI_AP_EXCLUSIVE = true; +#else +static constexpr bool WIFI_AP_EXCLUSIVE = false; +#endif +#endif +#ifdef USE_WIFI_AP_EXCLUSIVE +/// On a radio that cannot run the AP and STA together: how long the fallback +/// AP stays up while nobody uses it before it pauses so the networks can be +/// tried again, and how long new credentials give the portal to answer +/// before the AP drops. +static constexpr uint32_t WIFI_AP_EXCLUSIVE_DWELL_MS = 300000; +/// The AP pauses after this long even with clients counted, so a lost +/// disconnect event cannot keep the networks from being tried for good. +static constexpr uint32_t WIFI_AP_EXCLUSIVE_MAX_DWELL_MS = 3 * WIFI_AP_EXCLUSIVE_DWELL_MS; +static constexpr uint32_t WIFI_AP_EXCLUSIVE_HANDOVER_MS = 1000; +#endif /// Cooldown duration when fallback AP is active and captive portal may be running /// Longer interval gives users time to configure WiFi without constant connection attempts @@ -642,6 +661,14 @@ void WiFiComponent::setup() { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { if (this->ap_setup_) { ESP_LOGD(TAG, "Provisioning window closed; disabling AP"); +#ifdef USE_WIFI_AP_EXCLUSIVE + // The networks wait while the AP runs on its own; hand them the radio + // now rather than at the end of the dwell. + if (this->state_ == WIFI_COMPONENT_STATE_AP && this->has_sta()) { + this->pause_exclusive_ap_(); + return; + } +#endif this->wifi_mode_({}, false); } }); @@ -739,8 +766,11 @@ void WiFiComponent::start() { } #ifdef USE_CAPTIVE_PORTAL if (captive_portal::global_captive_portal != nullptr) { - this->wifi_sta_pre_setup_(); - this->start_scanning(); + // Where the radio scans alongside the AP, the portal can list networks. + if (!WIFI_AP_EXCLUSIVE) { + this->wifi_sta_pre_setup_(); + this->start_scanning(); + } captive_portal::global_captive_portal->start(); } #endif @@ -762,7 +792,7 @@ void WiFiComponent::restart_adapter() { // and check_connecting_finished() is called after cooldown without going // through start_connecting() first. Without this clear, stale errors would // trigger spurious "failed (callback)" logs. The canonical clear location - // is in start_connecting(); this is the only exception to that pattern. + // is in start_connecting() (ESP8266 also clears after wifi_station_connect()). this->error_from_callback_ = false; } @@ -862,8 +892,17 @@ void WiFiComponent::loop() { } break; } - case WIFI_COMPONENT_STATE_OFF: case WIFI_COMPONENT_STATE_AP: +#ifdef USE_WIFI_AP_EXCLUSIVE + // The networks are not tried while the AP is up, so it pauses for + // them once nobody has used it for a while. Without networks the AP + // is all there is and stays. + if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && + (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) + this->pause_exclusive_ap_(); + break; +#endif + case WIFI_COMPONENT_STATE_OFF: break; case WIFI_COMPONENT_STATE_DISABLED: return; @@ -879,11 +918,23 @@ void WiFiComponent::loop() { provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed(); #endif if (this->has_ap() && !this->ap_setup_ && !provisioning_closed) { - if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) { + if (this->ap_timeout_ != 0 && + (now - this->last_connected_ > this->ap_timeout_) +#ifdef USE_WIFI_AP_EXCLUSIVE + // After a pause, or a start that failed, the networks get a full + // ap_timeout before the AP is tried again. + && now - this->ap_exclusive_changed_ > this->ap_timeout_ +#endif + ) { ESP_LOGI(TAG, "Starting fallback AP"); +#ifdef USE_WIFI_AP_EXCLUSIVE + this->ap_exclusive_changed_ = now; +#endif this->setup_ap_config_(); #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { + // Where the AP runs on its own, a portal with no AP behind it would + // only stretch the cooldowns. + if (captive_portal::global_captive_portal != nullptr && (!WIFI_AP_EXCLUSIVE || this->ap_setup_)) { // Reset so we force one full scan after captive portal starts // (previous scans were filtered because captive portal wasn't active yet) this->has_completed_scan_after_captive_portal_start_ = false; @@ -972,7 +1023,13 @@ network::IPAddress WiFiComponent::get_dns_address(int num) { #ifdef USE_WIFI_AP void WiFiComponent::setup_ap_config_() { +#ifdef USE_WIFI_AP_EXCLUSIVE + // The radio cannot run both, so STA goes off while the AP is up. + if (!this->wifi_mode_(false, true)) + return; +#else this->wifi_mode_({}, true); +#endif if (this->ap_setup_) return; @@ -1006,6 +1063,12 @@ void WiFiComponent::setup_ap_config_() { } } this->ap_setup_ = this->wifi_start_ap_(this->ap_); +#ifdef USE_WIFI_AP_EXCLUSIVE + // A failed start must not leave the radio in AP mode, which would refuse + // the STA attempts until the next try. + if (!this->ap_setup_) + this->wifi_mode_({}, false); +#endif char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, @@ -1030,11 +1093,40 @@ void WiFiComponent::setup_ap_config_() { } #endif - if (!this->has_sta()) { + // Where the AP runs on its own, the STA state machine also waits in this + // state until the AP pauses. + if (!this->has_sta() || (WIFI_AP_EXCLUSIVE && this->ap_setup_)) { this->state_ = WIFI_COMPONENT_STATE_AP; } } +#ifdef USE_WIFI_AP_EXCLUSIVE +void WiFiComponent::pause_exclusive_ap_() { + ESP_LOGI(TAG, "Pausing AP to try the networks"); + // Scanning and connecting turn STA back on. + this->disable_ap_(); + this->ap_setup_ = false; + // Clients drop with the AP without a disconnect event for each. + this->ap_clients_ = 0; + this->ap_exclusive_changed_ = App.get_loop_component_start_time(); + // A fresh connection cycle, as at boot, rather than resuming the one the + // AP cut short. + this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; + this->num_retried_ = 0; + this->start_initial_connection_(); +} +#endif + +void WiFiComponent::disable_ap_() { +#ifdef USE_CAPTIVE_PORTAL + if (this->is_captive_portal_active_()) { + captive_portal::global_captive_portal->end(); + } +#endif + ESP_LOGD(TAG, "Disabling AP"); + this->wifi_mode_({}, false); +} + void WiFiComponent::set_ap(const WiFiAP &ap) { this->ap_ = ap; this->has_ap_ = true; @@ -1126,6 +1218,17 @@ void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { } void WiFiComponent::connect_soon_() { +#ifdef USE_WIFI_AP_EXCLUSIVE + // New credentials from the portal: the AP pauses for them once its answer + // is out, and the STA state machine picks them up from there. + if (this->ap_setup_) { + this->set_timeout("wifi-ap-handover", WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { + if (this->ap_setup_) + this->pause_exclusive_ap_(); + }); + return; + } +#endif // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); @@ -1218,8 +1321,9 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { // Clear any stale error from previous connection attempt. // This is the canonical location for clearing the flag since all connection - // attempts go through start_connecting(). The only other clear is in - // restart_adapter() which enters COOLDOWN without calling start_connecting(). + // attempts go through start_connecting(). restart_adapter() also clears it, as it + // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again + // after wifi_station_connect(), whose callbacks fire synchronously. this->error_from_callback_ = false; if (!this->wifi_sta_connect_(ap)) { @@ -1635,15 +1739,11 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; +#ifdef USE_WIFI_AP if (this->has_ap()) { -#ifdef USE_CAPTIVE_PORTAL - if (this->is_captive_portal_active_()) { - captive_portal::global_captive_portal->end(); - } -#endif - ESP_LOGD(TAG, "Disabling AP"); - this->wifi_mode_({}, false); + this->disable_ap_(); } +#endif #ifdef USE_IMPROV_BLE if (this->is_improv_ble_active_()) { improv_ble::global_improv_component->stop(); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 67913796499..9700e36e6f6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -303,7 +303,7 @@ class WiFiAP { bssid_t bssid_{}; // 6 bytes, all zeros = any/not set uint8_t channel_{0}; // 1 byte, 0 = auto/not set int8_t priority_{0}; // 1 byte - bool hidden_{false}; // 1 byte (+ 3 bytes end padding to 4-byte align) + bool hidden_{false}; // 1 byte; WiFiAP is byte aligned unless manual IP or EAP adds a 4 byte aligned member }; class WiFiScanResult { @@ -506,6 +506,9 @@ class WiFiComponent final : public Component { // (In most use cases you won't need these) /// Setup WiFi interface. void setup() override; +#ifdef USE_LN882X + void on_powerdown() override; +#endif void start(); void dump_config() override; void restart_adapter(); @@ -684,6 +687,12 @@ class WiFiComponent final : public Component { protected: #ifdef USE_WIFI_AP void setup_ap_config_(); + /// End the captive portal and turn the AP off. + void disable_ap_(); +#ifdef USE_WIFI_AP_EXCLUSIVE + /// Drop the fallback AP so the networks can be tried; it comes back after ap_timeout. + void pause_exclusive_ap_(); +#endif #endif // USE_WIFI_AP void print_connect_params_(); @@ -874,9 +883,6 @@ class WiFiComponent final : public Component { #ifdef WIFI_SCAN_RESULTS_LOCK_ENABLED Mutex scan_result_lock_; #endif -#ifdef USE_WIFI_AP - WiFiAP ap_; -#endif #ifdef USE_WIFI_IP_STATE_LISTENERS StaticVector ip_state_listeners_; #endif @@ -919,11 +925,17 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; +#ifdef USE_WIFI_AP_EXCLUSIVE + uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused +#endif 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_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py + // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the + // 1-byte members so they pack into its trailing bytes instead of padding after it + WiFiAP ap_; #endif // 1-byte enums and integers @@ -983,7 +995,8 @@ class WiFiComponent final : public Component { // Bools and bitfields // Pending listener callbacks deferred from platform callbacks to main loop. - struct { + // Empty when no listener needs deferring (e.g. ESP32 without connect state listeners) + [[no_unique_address]] struct { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Deferred until state machine reaches STA_CONNECTED so wifi.connected // condition returns true in listener automations. @@ -1007,6 +1020,9 @@ class WiFiComponent final : public Component { bool scan_done_{false}; bool ap_setup_{false}; bool ap_started_{false}; +#ifdef USE_WIFI_AP_EXCLUSIVE + uint8_t ap_clients_{0}; // Devices joined to the AP, which keep it from pausing +#endif bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; #ifdef USE_WIFI_11KV_SUPPORT diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 60ec3f9a4d5..aa1fa67b7bc 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -376,17 +376,17 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->wifi_apply_hostname_(); - // Reset flags, do this _before_ wifi_station_connect as the callback method - // may be called from wifi_station_connect - this->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTING); - ETS_UART_INTR_DISABLE(); ret = wifi_station_connect(); ETS_UART_INTR_ENABLE(); if (!ret) { ESP_LOGV(TAG, "wifi_station_connect failed"); + this->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_FAILED); return false; } + // Reset after, not before: wifi_station_connect() synchronously reports the attempt it replaces as failed + this->sta_state_ = static_cast(ESP8266WiFiSTAState::CONNECTING); + this->error_from_callback_ = false; #if USE_NETWORK_IPV6 bool connected = false; diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 24bf64a99ce..c44ee5b1de9 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -829,6 +829,12 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { (const char *) it.ssid, bssid_buf, it.channel, get_auth_mode_str(it.authmode)); #endif s_sta_connected = true; +#if USE_NETWORK_IPV6 + // Start SLAAC on association, not after a DHCPv4 lease that may never arrive. + if (esp_err_t ll_err = esp_netif_create_ip6_linklocal(s_sta_netif); ll_err != ESP_OK) { + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(ll_err)); + } +#endif /* USE_NETWORK_IPV6 */ if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED) { // Driver-initiated roam: the WIFI_REASON_ROAMING disconnect was ignored, // so the state machine never left STA_CONNECTED. @@ -881,9 +887,6 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) { const auto &it = data->data.ip_got_ip; -#if USE_NETWORK_IPV6 - esp_netif_create_ip6_linklocal(s_sta_netif); -#endif /* USE_NETWORK_IPV6 */ ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; #ifdef USE_WIFI_IP_STATE_LISTENERS diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 940f2a07830..28b30628fdc 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -28,6 +28,22 @@ extern "C" { } #endif +#ifdef USE_LN882X +#include +#undef DATA // clashes with setup_priority::DATA +// LibreTiny 1.13's LN882H reconnect() gives the SDK a NULL bssid; pass on the requested one. Remove once it passes +// info.bssid. Safe without a BSSID: begin() frees sta.bssid (resetNetworkInfo), so it stays NULL. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +// Names are mandated by the --wrap linker mechanism. +extern "C" int __real_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cfg_t *scan_cfg); +extern "C" int __wrap_wifi_sta_connect(wifi_sta_connect_t *connect, wifi_scan_cfg_t *scan_cfg) { + if (connect->bssid == nullptr) + connect->bssid = static_cast(WiFi.data)->sta.bssid; + return __real_wifi_sta_connect(connect, scan_cfg); +} +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +#endif + #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" @@ -608,6 +624,9 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STACONNECTED: { +#ifdef USE_WIFI_AP_EXCLUSIVE + this->ap_clients_++; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto &it = event->data.sta_connected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -617,6 +636,10 @@ void WiFiComponent::wifi_process_event_(LTWiFiEvent *event) { break; } case ESPHOME_EVENT_ID_WIFI_AP_STADISCONNECTED: { +#ifdef USE_WIFI_AP_EXCLUSIVE + if (this->ap_clients_ > 0) + this->ap_clients_--; +#endif #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE auto &it = event->data.sta_disconnected; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -770,6 +793,13 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { yield(); +#ifdef USE_LN882X + // LibreTiny's LN882H softAP() points ap.bssid at a static array and frees it + // on the next call, tripping FreeRTOS's configASSERT until the watchdog + // resets the chip. Clear it so that a second AP start frees nothing. + static_cast(WiFi.data)->ap.bssid = nullptr; +#endif + return WiFi.softAP(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), ap.has_channel() ? ap.get_channel() : 1, ap.get_hidden()); } @@ -777,6 +807,17 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } #endif // USE_WIFI_AP +#ifdef USE_LN882X +void WiFiComponent::on_powerdown() { + // Leave the AP so it no longer holds the association when we come back. + if (WiFi.status() != WL_CONNECTED) // associated, even before DHCP has finished + return; + ESP_LOGD(TAG, "Disconnecting before powerdown"); + wifi_sta_disconnect(); // not WiFi.disconnect(): it frees the SSID its event handlers read + delay(100); // NOLINT: once per reboot or deep sleep, lets the deauth go out +} +#endif + bool WiFiComponent::wifi_disconnect_() { // Reset state first so disconnect events aren't ignored // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index ea9e5a3b0c9..3b67aa46d01 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): @@ -153,57 +143,27 @@ async def to_code(config): if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_LWIP_PPP_SUPPORT", True) - # This flag is added here because the esp_wireguard library statically + # This flag is added here because the wireguard library statically # set the size of its allowed_ips list at compile time using this value; # the '+1' modifier is relative to the device's own address that will # be automatically added to the provided list. cg.add_build_flag(f"-DCONFIG_WIREGUARD_MAX_SRC_IPS={len(allowed_ips) + 1}") - cg.add_library("droscy/esp_wireguard", "0.4.5") + cg.add_library("esphome/wireguard", "0.4.8") 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.cpp b/esphome/components/wireguard/wireguard.cpp index fc06569fbab..1fd610f4e88 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -101,8 +101,13 @@ void Wireguard::update() { if (this->wg_peer_offline_time_ == 0) { ESP_LOGW(TAG, LOGMSG_PEER_STATUS, LOGMSG_OFFLINE, latest_handshake.c_str()); this->wg_peer_offline_time_ = millis(); + this->wg_reconnect_time_ = this->wg_peer_offline_time_; } else if (this->enabled_) { ESP_LOGD(TAG, LOGMSG_PEER_STATUS, LOGMSG_OFFLINE, latest_handshake.c_str()); + if (millis() - this->wg_reconnect_time_ >= RECONNECT_INTERVAL_MS) { + this->wg_reconnect_time_ = millis(); + this->stop_connection_(); + } this->start_connection_(); } diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c9c2feb7aef..faf63cd444c 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" @@ -141,6 +140,7 @@ class Wireguard final : public PollingComponent { /// The last time the remote peer become offline. uint32_t wg_peer_offline_time_ = 0; + uint32_t wg_reconnect_time_ = 0; /** \brief The latest saved handshake. * @@ -160,34 +160,10 @@ void resume_wdt(); /// Size of buffer required for mask_key_to: 5 chars + "[...]=" + null = 12 static constexpr size_t MASK_KEY_BUFFER_SIZE = 12; +static constexpr uint32_t RECONNECT_INTERVAL_MS = 30000; /// 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/sensor.py b/esphome/components/xdb401/sensor.py index c629e466503..df274ac09b3 100644 --- a/esphome/components/xdb401/sensor.py +++ b/esphome/components/xdb401/sensor.py @@ -56,10 +56,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_pressure_range_bar(config[CONF_PRESSURE_RANGE_BAR])) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) 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/xgzp68xx/sensor.py b/esphome/components/xgzp68xx/sensor.py index 83c20dfbd8d..eb3b042cb7a 100644 --- a/esphome/components/xgzp68xx/sensor.py +++ b/esphome/components/xgzp68xx/sensor.py @@ -70,13 +70,11 @@ 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)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) - 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 await sensors(CONF_PRESSURE, var.set_pressure_sensor): + oversampling = config[CONF_PRESSURE][CONF_OVERSAMPLING] + cg.add(var.set_pressure_oversampling(oversampling)) cg.add(var.set_k_value(config[CONF_K_VALUE])) 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_cgd1/sensor.py b/esphome/components/xiaomi_cgd1/sensor.py index 09337df5cab..162ba7016be 100644 --- a/esphome/components/xiaomi_cgd1/sensor.py +++ b/esphome/components/xiaomi_cgd1/sensor.py @@ -66,12 +66,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_cgdk2/sensor.py b/esphome/components/xiaomi_cgdk2/sensor.py index 83de32f4119..e1d44d6bac5 100644 --- a/esphome/components/xiaomi_cgdk2/sensor.py +++ b/esphome/components/xiaomi_cgdk2/sensor.py @@ -66,12 +66,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_cgg1/sensor.py b/esphome/components/xiaomi_cgg1/sensor.py index 3f0df9eafec..01ca8d1f84d 100644 --- a/esphome/components/xiaomi_cgg1/sensor.py +++ b/esphome/components/xiaomi_cgg1/sensor.py @@ -67,12 +67,7 @@ async def to_code(config: ConfigType) -> None: if CONF_BINDKEY in config: 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_cgpr1/binary_sensor.py b/esphome/components/xiaomi_cgpr1/binary_sensor.py index 219432c1fc6..b742f1eaec2 100644 --- a/esphome/components/xiaomi_cgpr1/binary_sensor.py +++ b/esphome/components/xiaomi_cgpr1/binary_sensor.py @@ -71,12 +71,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_IDLE_TIME in config: - sens = await sensor.new_sensor(config[CONF_IDLE_TIME]) - cg.add(var.set_idle_time(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_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_IDLE_TIME, var.set_idle_time) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) diff --git a/esphome/components/xiaomi_gcls002/sensor.py b/esphome/components/xiaomi_gcls002/sensor.py index 63ba5da5972..75138970246 100644 --- a/esphome/components/xiaomi_gcls002/sensor.py +++ b/esphome/components/xiaomi_gcls002/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_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)) + 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) 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_hhccpot002/sensor.py b/esphome/components/xiaomi_hhccpot002/sensor.py index 061753c44c6..73d5f3ff192 100644 --- a/esphome/components/xiaomi_hhccpot002/sensor.py +++ b/esphome/components/xiaomi_hhccpot002/sensor.py @@ -53,9 +53,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_MOISTURE in config: - sens = await sensor.new_sensor(config[CONF_MOISTURE]) - cg.add(var.set_moisture(sens)) - if CONF_CONDUCTIVITY in config: - sens = await sensor.new_sensor(config[CONF_CONDUCTIVITY]) - cg.add(var.set_conductivity(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_MOISTURE, var.set_moisture) + await sensors(CONF_CONDUCTIVITY, var.set_conductivity) diff --git a/esphome/components/xiaomi_jqjcy01ym/sensor.py b/esphome/components/xiaomi_jqjcy01ym/sensor.py index b791c40dca6..4bfe9c8f756 100644 --- a/esphome/components/xiaomi_jqjcy01ym/sensor.py +++ b/esphome/components/xiaomi_jqjcy01ym/sensor.py @@ -72,15 +72,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_HUMIDITY in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY]) - cg.add(var.set_humidity(sens)) - if CONF_FORMALDEHYDE in config: - sens = await sensor.new_sensor(config[CONF_FORMALDEHYDE]) - cg.add(var.set_formaldehyde(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_FORMALDEHYDE, var.set_formaldehyde) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_lywsd02/sensor.py b/esphome/components/xiaomi_lywsd02/sensor.py index f79b81dce27..d37d7540c76 100644 --- a/esphome/components/xiaomi_lywsd02/sensor.py +++ b/esphome/components/xiaomi_lywsd02/sensor.py @@ -63,12 +63,7 @@ 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)) + 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_lywsd02mmc/sensor.py b/esphome/components/xiaomi_lywsd02mmc/sensor.py index 05e0204defb..5b088318ae7 100644 --- a/esphome/components/xiaomi_lywsd02mmc/sensor.py +++ b/esphome/components/xiaomi_lywsd02mmc/sensor.py @@ -67,12 +67,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 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)) + 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_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_lywsdcgq/sensor.py b/esphome/components/xiaomi_lywsdcgq/sensor.py index 8f0b93a22ee..75a8a2d847e 100644 --- a/esphome/components/xiaomi_lywsdcgq/sensor.py +++ b/esphome/components/xiaomi_lywsdcgq/sensor.py @@ -63,12 +63,7 @@ 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)) + 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_mccgq02hl/__init__.py b/esphome/components/xiaomi_mccgq02hl/__init__.py new file mode 100644 index 00000000000..3aecbd82079 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@ahpohl", "@morph027"] diff --git a/esphome/components/xiaomi_mccgq02hl/binary_sensor.py b/esphome/components/xiaomi_mccgq02hl/binary_sensor.py new file mode 100644 index 00000000000..f410ad7f739 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/binary_sensor.py @@ -0,0 +1,65 @@ +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_BATTERY_LEVEL, + CONF_BINDKEY, + CONF_LIGHT, + CONF_MAC_ADDRESS, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_OPENING, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["ble_device_base", "sensor"] + +xiaomi_mccgq02hl_ns = cg.esphome_ns.namespace("xiaomi_mccgq02hl") +XiaomiMCCGQ02HL = xiaomi_mccgq02hl_ns.class_( + "XiaomiMCCGQ02HL", + binary_sensor.BinarySensor, + cg.Component, + ble_device_base.ESPBTDeviceListener, +) + +CONFIG_SCHEMA = cv.All( + ble_device_base.rename_legacy_hub_id("xiaomi_mccgq02hl"), + binary_sensor.binary_sensor_schema( + XiaomiMCCGQ02HL, device_class=DEVICE_CLASS_OPENING + ) + .extend( + { + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Required(CONF_BINDKEY): cv.bind_key, + cv.Optional(CONF_LIGHT): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_LIGHT + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(ble_device_base.BLE_DEVICE_SCHEMA), +) + + +async def to_code(config: ConfigType) -> None: + var = await binary_sensor.new_binary_sensor(config) + await cg.register_component(var, config) + await ble_device_base.register_ble_device(var, config) + + cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) + cg.add(var.set_bindkey(config[CONF_BINDKEY])) + + sensors = sensor.sub_sensors(config) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_LIGHT, var.set_light) diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp new file mode 100644 index 00000000000..88b9865e476 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.cpp @@ -0,0 +1,168 @@ +#include "xiaomi_mccgq02hl.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_mccgq02hl { + +static const char *const TAG = "xiaomi_mccgq02hl"; + +static constexpr uint16_t PRODUCT_ID = 0x098b; + +// MiBeacon frame control, low byte (raw[0]) +static constexpr uint8_t FC_ENCRYPTED = 0x08; +static constexpr uint8_t FC_MAC_INCLUDED = 0x10; +static constexpr uint8_t FC_CAPABILITY = 0x20; +static constexpr uint8_t FC_OBJECT = 0x40; + +// Encrypted frames end in a 3-byte extended counter and a 4-byte MIC. +static constexpr size_t MIC_SIZE = 4; +static constexpr size_t ENCRYPTED_TRAILER = 3 + MIC_SIZE; +// Largest encrypted payload accepted; MCCGQ02HL objects are 4 bytes. +static constexpr size_t MAX_PAYLOAD_SIZE = 16; + +// Object ids. The door and light objects come in both the 0x00xx and the +// 0x10xx flavour depending on firmware; the semantics are identical. +static constexpr uint16_t OBJ_LIGHT = 0x0018; +static constexpr uint16_t OBJ_LIGHT_ALT = 0x1018; +static constexpr uint16_t OBJ_DOOR = 0x0019; +static constexpr uint16_t OBJ_DOOR_ALT = 0x1019; +static constexpr uint16_t OBJ_BATTERY = 0x100A; +static constexpr uint16_t OBJ_BATTERY_ALT = 0x4803; + +void XiaomiMCCGQ02HL::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 MCCGQ02HL\n" + " MAC Address: %s", + format_mac_addr_upper(mac, mac_buf)); + LOG_BINARY_SENSOR(" ", "Opening", this); + LOG_BINARY_SENSOR(" ", "Light", this->light_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_); +} + +void XiaomiMCCGQ02HL::set_bindkey(const char *bindkey) { parse_hex(bindkey, this->bindkey_, sizeof(this->bindkey_)); } + +bool XiaomiMCCGQ02HL::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + bool success = false; + for (auto &service_data : device.get_service_datas()) { + if (!service_data.uuid.contains(0x95, 0xFE)) + continue; + + Reading reading; + if (!this->parse_service_data_(service_data.data, reading)) + continue; + + ESP_LOGD(TAG, "%s: open=%s light=%s battery=%s", this->get_name().c_str(), + reading.open.has_value() ? (*reading.open ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no")) + : LOG_STR_LITERAL("-"), + reading.light.has_value() ? (*reading.light ? LOG_STR_LITERAL("yes") : LOG_STR_LITERAL("no")) + : LOG_STR_LITERAL("-"), + reading.battery_level.has_value() ? LOG_STR_LITERAL("updated") : LOG_STR_LITERAL("-")); + + if (reading.open.has_value()) + this->publish_state(*reading.open); + if (reading.light.has_value() && this->light_ != nullptr) + this->light_->publish_state(*reading.light); + if (reading.battery_level.has_value() && this->battery_level_ != nullptr) + this->battery_level_->publish_state(*reading.battery_level); + success = true; + } + return success; +} + +bool XiaomiMCCGQ02HL::decrypt_(const uint8_t *frame, size_t size, size_t offset, 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 + 2, 3); // product id + frame count + memcpy(nonce + MAC_ADDRESS_SIZE + 3, frame + size - ENCRYPTED_TRAILER, 3); // extended counter + 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 + offset, size - offset - ENCRYPTED_TRAILER, plaintext, + frame + size - MIC_SIZE, MIC_SIZE); +} + +bool XiaomiMCCGQ02HL::parse_service_data_(const std::vector &data, Reading &reading) { + const size_t size = data.size(); + if (size < 5) + return false; + const uint8_t *raw = data.data(); + + const uint8_t fc = raw[0]; + if (!(fc & FC_OBJECT)) + return false; + if (encode_uint16(raw[3], raw[2]) != PRODUCT_ID) { + ESP_LOGVV(TAG, "Not an MCCGQ02HL frame (product id %02X%02X).", raw[3], raw[2]); + return false; + } + + const uint8_t frame_count = raw[4]; + if (this->last_frame_count_.has_value() && *this->last_frame_count_ == frame_count) { + ESP_LOGVV(TAG, "Duplicate frame %u.", frame_count); + return false; + } + + // The bindkey is required, so plaintext frames are never trusted + if (!(fc & FC_ENCRYPTED)) { + ESP_LOGVV(TAG, "Ignoring unencrypted frame %u.", frame_count); + return false; + } + + const size_t offset = 5 + ((fc & FC_MAC_INCLUDED) ? 6 : 0) + ((fc & FC_CAPABILITY) ? 1 : 0); + if (size <= offset + ENCRYPTED_TRAILER || size - offset - ENCRYPTED_TRAILER > MAX_PAYLOAD_SIZE) { + ESP_LOGW(TAG, "Unsupported encrypted frame layout (fc=0x%02X, %u bytes).", fc, (unsigned) size); + return false; + } + uint8_t plaintext[MAX_PAYLOAD_SIZE]; + if (!this->decrypt_(raw, size, offset, plaintext)) { + ESP_LOGW(TAG, "Decryption failed (%u-byte frame) -- check the bindkey.", (unsigned) size); + return false; + } + // Only an authenticated frame may advance the duplicate filter. There is no replay protection: the + // counter is not persisted and restarts on a battery change, so a strictly increasing check could lock + // the sensor out until the next reboot. + this->last_frame_count_ = frame_count; + return this->parse_objects_(plaintext, size - offset - ENCRYPTED_TRAILER, reading); +} + +bool XiaomiMCCGQ02HL::parse_objects_(const uint8_t *payload, size_t length, Reading &reading) { + bool found = false; + // Each object: id (uint16 LE), length (uint8), value. + while (length >= 3) { + const uint16_t id = encode_uint16(payload[1], payload[0]); + const uint8_t len = payload[2]; + if (len < 1 || length < 3u + len) + break; + const uint8_t *value = payload + 3; + + if ((id == OBJ_DOOR || id == OBJ_DOOR_ALT) && len == 1) { + // 0 = open, 1 = closed, 2 = left open past timeout, 3 = device reset + if (value[0] <= 2) { + reading.open = value[0] != 1; + found = true; + } + } else if ((id == OBJ_LIGHT || id == OBJ_LIGHT_ALT) && len == 1) { + reading.light = value[0] != 0; + found = true; + } else if ((id == OBJ_BATTERY || id == OBJ_BATTERY_ALT) && len == 1) { + reading.battery_level = value[0]; + found = true; + } else { + ESP_LOGVV(TAG, "Ignoring object 0x%04X (%u bytes).", id, len); + } + + payload += 3 + len; + length -= 3 + len; + } + return found; +} + +} // namespace esphome::xiaomi_mccgq02hl diff --git a/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h new file mode 100644 index 00000000000..8897752b449 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/xiaomi_mccgq02hl.h @@ -0,0 +1,50 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/optional.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/ble_device_base/ble_device.h" + +#include + +namespace esphome::xiaomi_mccgq02hl { + +// Xiaomi Mijia Door/Window Sensor 2 (MCCGQ02HL), MiBeacon product id 0x098b. +// +// Self-contained on purpose: xiaomi_ble's parse_xiaomi_header() does not know +// this product id and parse_xiaomi_value() does not know the door object, so +// this class does its own header and object parsing and decryption. +class XiaomiMCCGQ02HL final : public Component, + public binary_sensor::BinarySensorInitiallyOff, + public ble_device_base::ESPBTDeviceListener { + public: + void set_address(uint64_t address) { this->address_ = address; } + void set_bindkey(const char *bindkey); + void set_light(binary_sensor::BinarySensor *light) { this->light_ = light; } + void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; } + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + protected: + struct Reading { + optional open; + optional light; + optional battery_level; + }; + + bool decrypt_(const uint8_t *frame, size_t size, size_t offset, uint8_t *plaintext) const; + bool parse_service_data_(const std::vector &data, Reading &reading); + bool parse_objects_(const uint8_t *payload, size_t length, Reading &reading); + + uint64_t address_{0}; + uint8_t bindkey_[16]{}; + // Per instance, unlike xiaomi_ble's function-static counter which is shared + // by every Xiaomi device on the node. + optional last_frame_count_; + binary_sensor::BinarySensor *light_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; +}; + +} // namespace esphome::xiaomi_mccgq02hl diff --git a/esphome/components/xiaomi_mhoc303/sensor.py b/esphome/components/xiaomi_mhoc303/sensor.py index 9ce544fc59a..44fb9c76548 100644 --- a/esphome/components/xiaomi_mhoc303/sensor.py +++ b/esphome/components/xiaomi_mhoc303/sensor.py @@ -63,12 +63,7 @@ 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)) + 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_mhoc401/sensor.py b/esphome/components/xiaomi_mhoc401/sensor.py index 52cae36fcdb..e74c656e512 100644 --- a/esphome/components/xiaomi_mhoc401/sensor.py +++ b/esphome/components/xiaomi_mhoc401/sensor.py @@ -67,12 +67,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_miscale/sensor.py b/esphome/components/xiaomi_miscale/sensor.py index fa4296d7a9d..c6277a515d7 100644 --- a/esphome/components/xiaomi_miscale/sensor.py +++ b/esphome/components/xiaomi_miscale/sensor.py @@ -58,9 +58,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) cg.add(var.set_clear_impedance(config[CONF_CLEAR_IMPEDANCE])) - if CONF_WEIGHT in config: - sens = await sensor.new_sensor(config[CONF_WEIGHT]) - cg.add(var.set_weight(sens)) - if CONF_IMPEDANCE in config: - sens = await sensor.new_sensor(config[CONF_IMPEDANCE]) - cg.add(var.set_impedance(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_WEIGHT, var.set_weight) + await sensors(CONF_IMPEDANCE, var.set_impedance) diff --git a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py index 5d562a2ca91..be5827c1719 100644 --- a/esphome/components/xiaomi_mjyd02yla/binary_sensor.py +++ b/esphome/components/xiaomi_mjyd02yla/binary_sensor.py @@ -77,15 +77,9 @@ 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_IDLE_TIME in config: - sens = await sensor.new_sensor(config[CONF_IDLE_TIME]) - cg.add(var.set_idle_time(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_ILLUMINANCE in config: - sens = await sensor.new_sensor(config[CONF_ILLUMINANCE]) - cg.add(var.set_illuminance(sens)) - if CONF_LIGHT in config: - sens = await binary_sensor.new_binary_sensor(config[CONF_LIGHT]) - cg.add(var.set_light(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_IDLE_TIME, var.set_idle_time) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_ILLUMINANCE, var.set_illuminance) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_LIGHT, var.set_light) 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/xiaomi_wx08zm/binary_sensor.py b/esphome/components/xiaomi_wx08zm/binary_sensor.py index 4a6267f8b9d..8e9b32b797c 100644 --- a/esphome/components/xiaomi_wx08zm/binary_sensor.py +++ b/esphome/components/xiaomi_wx08zm/binary_sensor.py @@ -56,9 +56,6 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if CONF_TABLET in config: - sens = await sensor.new_sensor(config[CONF_TABLET]) - cg.add(var.set_tablet(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_TABLET, var.set_tablet) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) diff --git a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py index 82825231294..8c47b526b1d 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/sensor.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/sensor.py @@ -67,12 +67,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 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)) + 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/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 5686b741738..2656b60c103 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -54,7 +54,7 @@ def validate_pin(pin: int) -> int: XL9535_PIN_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(XL9535GPIOPin), - cv.Required(CONF_XL9535): cv.use_id(XL9535Component), + cv.Required(CONF_XL9535): pins.use_id_or_address(XL9535Component), cv.Required(CONF_NUMBER): cv.All(cv.int_range(min=0, max=17), validate_pin), cv.Optional(CONF_MODE, default={}): cv.All( { 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..0a99db8958d 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, @@ -42,7 +42,9 @@ from .const_zephyr import ( from .zigbee_esp32 import ( final_validate_esp32, validate_binary_sensor_esp32, + validate_number_esp32, validate_sensor_esp32, + validate_switch_esp32, zigbee_require_vfs_select, ) from .zigbee_zephyr import ( @@ -67,30 +69,61 @@ def _check_report_deprecation(value: str) -> str: return value -BASE_SCHEMA = cv.Schema( - { - cv.Optional(CONF_REPORT): cv.All( - cv.requires_component("zigbee"), - cv.requires_component("esp32"), - _check_report_deprecation, - cv.enum(REPORT, lower=True), - ), - cv.Optional(CONF_ENDPOINT): cv.All( - cv.requires_component("zigbee"), - cv.requires_component("esp32"), - cv.int_range(1, CONF_MAX_EP_NUMBER), - ), - cv.Optional(CONF_USE_DEVICE_TYPE): cv.All( - cv.requires_component("zigbee"), - cv.requires_component("esp32"), - cv.boolean, - ), - } +def _get_base_schema(cluster_options: list[str] | None = None) -> cv.Schema: + schema = cv.Schema( + { + cv.Optional(CONF_REPORT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + _check_report_deprecation, + cv.enum(REPORT, lower=True), + ), + cv.Optional(CONF_ENDPOINT): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.int_range(1, CONF_MAX_EP_NUMBER), + ), + cv.Optional(CONF_USE_DEVICE_TYPE): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.boolean, + ), + } + ) + if cluster_options: + schema = cv.Schema( + { + cv.OnlyWith( + CONF_CLUSTER, ["esp32", "zigbee"], default=cluster_options[0] + ): cv.All( + cv.requires_component("zigbee"), + cv.requires_component("esp32"), + cv.one_of(*cluster_options, lower=True), + ), + } + ).extend(schema) + return schema + + +# set BASE_SCHEMA for CI and backwards compatibility +BASE_SCHEMA = _get_base_schema() + +BINARY_SENSOR_SCHEMA = ( + cv.Schema({}) + .extend(_get_base_schema(["generic", "device_class"])) + .extend(zephyr_binary_sensor) +) +SENSOR_SCHEMA = ( + cv.Schema({}) + .extend(_get_base_schema(["generic", "device_class"])) + .extend(zephyr_sensor) +) +SWITCH_SCHEMA = ( + cv.Schema({}).extend(_get_base_schema(["generic", "on_off"])).extend(zephyr_switch) +) +NUMBER_SCHEMA = ( + cv.Schema({}).extend(_get_base_schema(["generic"])).extend(zephyr_number) ) -BINARY_SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_binary_sensor) -SENSOR_SCHEMA = cv.Schema({}).extend(BASE_SCHEMA).extend(zephyr_sensor) -SWITCH_SCHEMA = cv.Schema({}).extend(zephyr_switch) -NUMBER_SCHEMA = cv.Schema({}).extend(zephyr_number) def _validate_router_sleepy(config: ConfigType) -> ConfigType: @@ -197,30 +230,39 @@ 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: - 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_switch - - await zephyr_setup_switch(entity, config) + if not config.get(CONF_ZIGBEE_ID): + return + from .zigbee_zephyr import add_switch + else: + from .zigbee_esp32 import add_component as add_switch + CORE.add_job(add_switch, entity, config) async def setup_number( @@ -230,12 +272,18 @@ async def setup_number( max_value: float, step: float, ) -> 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: + if not config.get(CONF_ZIGBEE_ID): + return from .zigbee_zephyr import zephyr_setup_number await zephyr_setup_number(entity, config, min_value, max_value, step) + else: + from .zigbee_esp32 import add_component as add_number + + CORE.add_job(add_number, entity, config) def consume_endpoint(config: ConfigType) -> ConfigType: @@ -273,7 +321,7 @@ def validate_switch(config: ConfigType) -> ConfigType: if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: - return config + return validate_switch_esp32(config) return consume_endpoint(config) @@ -281,7 +329,7 @@ def validate_number(config: ConfigType) -> ConfigType: if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: - return config + return validate_number_esp32(config) return consume_endpoint(config) @@ -293,23 +341,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..86cac665b43 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" @@ -144,7 +145,8 @@ class AnalogInputType(IntEnum): TIME_SECONDS = 0x0E -ANALOG_INPUT_APPTYPE = { +# Same for input and output +ANALOG_APPTYPE = { (DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS): AnalogInputType.TEMP_DEGREES_C, (DEVICE_CLASS_HUMIDITY, UNIT_PERCENT): AnalogInputType.RELATIVE_HUMIDITY_PERCENT, (DEVICE_CLASS_PRESSURE, UNIT_PASCAL): AnalogInputType.PRESSURE_PASCAL, diff --git a/esphome/components/zigbee/const_esp32.py b/esphome/components/zigbee/const_esp32.py index 2e1b09fb221..bb6c153752b 100644 --- a/esphome/components/zigbee/const_esp32.py +++ b/esphome/components/zigbee/const_esp32.py @@ -1,27 +1,47 @@ 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" DEVICE_ID = { + "ON_OFF_OUTPUT": cg.RawExpression("EZB_ZHA_ON_OFF_OUTPUT_DEVICE_ID"), "RANGE_EXTENDER": cg.RawExpression("EZB_ZHA_RANGE_EXTENDER_DEVICE_ID"), + "MAINS_POWER_OUTLET": cg.RawExpression("EZB_ZHA_MAINS_POWER_OUTLET_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") CLUSTER_ID = { "BASIC": cluster_id.EZB_ZCL_CLUSTER_ID_BASIC, + "ON_OFF": cluster_id.EZB_ZCL_CLUSTER_ID_ON_OFF, "TIME": cluster_id.EZB_ZCL_CLUSTER_ID_TIME, "BINARY_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_INPUT, + "BINARY_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_BINARY_OUTPUT, "ANALOG_INPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_INPUT, + "ANALOG_OUTPUT": cluster_id.EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT, + "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 +51,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/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..305febf171a 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" @@ -18,6 +20,12 @@ #ifdef USE_BINARY_SENSOR #include "esphome/components/binary_sensor/binary_sensor.h" #endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif +#ifdef USE_NUMBER +#include "esphome/components/number/number.h" +#endif namespace esphome::zigbee { @@ -45,11 +53,26 @@ class ZigbeeAttribute final : public Component { template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); + + template void add_on_value_callback(F &&callback) { on_value_callback_.add(std::forward(callback)); } + void on_value(ezb_zcl_attribute_t attribute) { + if (attribute.data.type == this->attr_type() && attribute.data.value) { + this->on_value_callback_.call(attribute); + } + } + #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); +#endif +#ifdef USE_SWITCH + template void connect(switch_::Switch *device); +#endif +#ifdef USE_NUMBER + template void connect(number::Number *device); #endif bool report_enabled = false; @@ -68,6 +91,9 @@ class ZigbeeAttribute final : public Component { bool set_attr_requested_{false}; bool report_requested_{false}; bool force_report_{false}; + LazyCallbackManager on_value_callback_{}; + template T scale_value_(float value); + template T invalid_value_(); }; template void ZigbeeAttribute::add_attr(T value) { @@ -85,9 +111,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 @@ -95,6 +156,32 @@ template void ZigbeeAttribute::connect(binary_sensor::BinarySensor * sensor->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); } #endif +#ifdef USE_SWITCH +template void ZigbeeAttribute::connect(switch_::Switch *device) { + this->add_on_value_callback( + [device](ezb_zcl_attribute_t attribute) { device->control(*(T *) attribute.data.value); }); + device->add_on_state_callback([this](bool value) { this->set_attr((T) (this->scale_ * value)); }); +} +#endif +#ifdef USE_NUMBER +template void ZigbeeAttribute::connect(number::Number *device) { + // Add min, max and step attributes to the analog output cluster + if (this->cluster_id_ == EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT) { + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x0045, this->max_size_, + device->traits.get_min_value()); + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x0041, this->max_size_, + device->traits.get_max_value()); + this->zb_->add_attr(this->endpoint_id_, this->cluster_id_, this->role_, 0x006A, this->max_size_, + device->traits.get_step()); + } + this->add_on_value_callback([this, device](ezb_zcl_attribute_t attribute) { + auto call = device->make_call(); + call.set_value(static_cast(*(T *) attribute.data.value) / this->scale_); + call.perform(); + }); + device->add_on_state_callback([this](float value) { this->set_attr(this->scale_value_(value)); }); +} +#endif } // namespace esphome::zigbee diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 700267ef50e..88f49ef3c16 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,342 @@ 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", + }, + ], + }, + ], +} + +ANALOG_OUTPUT_EP = { + CONF_CLUSTERS: [ + { + CONF_ID: "ANALOG_OUTPUT", + 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", + }, + ], + }, + ], +} + +BINARY_OUTPUT_EP = { + CONF_CLUSTERS: [ + { + CONF_ID: "BINARY_OUTPUT", + 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 + }, + ], + }, + ], + }, +} + +SWITCH_EP_CONFIGS: dict[str, dict[str, Any]] = { + "on_off": { + DEVICE_TYPE: "ON_OFF_OUTPUT", + CONF_CLUSTERS: [ + { + CONF_ID: "ON_OFF", + ROLE: "SERVER", + CONF_ATTRIBUTES: [ + { + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "BOOL", + CONF_REPORT: cv.enum(REPORT, lower=True)("default"), + CONNECT: True, }, ], }, diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 4f9c70da75c..c6597ebfd69 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -154,6 +154,31 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { return true; } +void load_zb_event(ZBEvent *event, ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute) { + event->load_set_attr_value_event(info, attribute); +} + +template void enqueue_zb_event(Args... args) { + // Allocate an event from the pool + ZBEvent *event = global_zigbee->zb_event_pool_.allocate(); + if (event == nullptr) { + // No events available - queue is full or we're out of memory + global_zigbee->zb_events_.increment_dropped_count(); + return; + } + + // Load new event data (replaces previous event) + load_zb_event(event, args...); + + // Push the event to the queue + global_zigbee->zb_events_.push(event); + // Push always succeeds because we're the only producer and the pool ensures we never exceed queue size + global_zigbee->enable_loop_soon_any_context(); +} + +// Explicit template instantiations for the friend function +template void enqueue_zb_event(ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute); + static void zb_attribute_handler(ezb_zcl_set_attr_value_message_t *message) { ESP_RETURN_ON_FALSE(message, , TAG, "Empty message"); ESP_RETURN_ON_FALSE(message->info.status == EZB_ZCL_STATUS_SUCCESS, , TAG, "Received message: error status(%d)", @@ -161,6 +186,7 @@ static void zb_attribute_handler(ezb_zcl_set_attr_value_message_t *message) { ESP_LOGD(TAG, "ZCL SetAttributeValue message for endpoint(%d) cluster(0x%04x) %s with status(0x%02x)", message->info.dst_ep, message->info.cluster_id, message->info.cluster_role == EZB_ZCL_CLUSTER_SERVER ? "server" : "client", message->info.status); + enqueue_zb_event(message->info, message->in.attribute); } static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, void *message) { @@ -180,14 +206,16 @@ static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, voi } } +void ZigbeeComponent::handle_attribute_(ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute) { + auto it = this->attributes_.find({info.dst_ep, info.cluster_id, info.cluster_role, attribute.id}); + if (it != this->attributes_.end()) { + it->second->on_value(attribute); + } +} + 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); } @@ -232,13 +260,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() { @@ -272,7 +300,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(), @@ -299,11 +329,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(); @@ -341,12 +367,45 @@ 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 } void ZigbeeComponent::loop() { + // Process all pending events + ZBEvent *event = this->zb_events_.pop(); + while (event != nullptr) { + // Handle the event + switch (event->callback_id_) { + case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: + this->handle_attribute_(event->event_.set_attr.info, event->event_.set_attr.attribute); + break; + default: + ESP_LOGW(TAG, "Received event with unhandled callback id: 0x%x", static_cast(event->callback_id_)); + break; + } + + // Free the event back to the pool + this->zb_event_pool_.release(event); + // Get the next event + event = this->zb_events_.pop(); + } + // Log dropped events periodically + uint16_t dropped = this->zb_events_.get_and_reset_dropped_count(); + if (dropped > 0) { + ESP_LOGW(TAG, "Dropped %u Zigbee events due to buffer overflow", dropped); + } + if (!this->start_reported_ && this->started_) { this->start_cb_.call(); this->start_reported_ = true; @@ -383,6 +442,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..093ae959390 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -12,11 +12,10 @@ #include "ezbee/zha.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/lock_free_queue.h" +#include "esphome/core/event_pool.h" #include "zigbee_helpers_esp32.h" - -#ifdef USE_BINARY_SENSOR -#include "esphome/components/binary_sensor/binary_sensor.h" -#endif +#include "zigbee_event_esp32.h" namespace esphome::zigbee { @@ -24,17 +23,22 @@ namespace esphome::zigbee { static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */ static const uint8_t MAX_CHILDREN = 10; static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */ +static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32; #define EZB_DEFAULT_RADIO_CONFIG() \ { .radio_mode = ESP_ZIGBEE_RADIO_MODE_NATIVE, } 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; @@ -84,18 +88,31 @@ class ZigbeeComponent final : public Component { template void add_attr_(ZigbeeAttribute *attr, uint8_t endpoint_id, uint16_t cluster_id, uint8_t role, uint16_t attr_id, T *value_p); + void handle_attribute_(ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute); // attributes_ will be used during operation in zigbee callbacks to update the attribute values and trigger // 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_{}; + template friend void enqueue_zb_event(Args... args); + esphome::LockFreeQueue zb_events_; + // Pool sized to queue capacity (SIZE-1) because LockFreeQueue is a ring + // buffer that holds N-1 elements (one slot distinguishes full from empty). + // This guarantees allocate() returns nullptr before push() can fail, which: + // 1. Prevents leaking a pool slot (the Nth allocate succeeds but push fails) + // 2. Avoids needing release() on the producer path after a failed push(), + // preserving the SPSC contract on the pool's internal free list + esphome::EventPool zb_event_pool_; LazyCallbackManager start_cb_{}; bool start_reported_{false}; std::atomic started_ = false; 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 +127,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 +162,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..103e901b13b 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -10,15 +10,17 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, + require_mbedtls_ecp, + 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, @@ -26,6 +28,7 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WIFI, + DEVICE_CLASS_OUTLET, ) from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -34,9 +37,10 @@ import esphome.final_validate as fv from esphome.types import ConfigType from .const import ( - ANALOG_INPUT_APPTYPE, + ANALOG_APPTYPE, BACNET_UNIT_NO_UNITS, BACNET_UNITS, + CONF_CLUSTER, CONF_ENDPOINT, CONF_POWER_SOURCE, CONF_REPORT, @@ -47,19 +51,32 @@ 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, + ANALOG_OUTPUT_EP, + BINARY_INPUT_EP, + BINARY_OUTPUT_EP, + BINARY_SENSOR_EP_CONFIGS, + SENSOR_EP_CONFIGS, + SWITCH_EP_CONFIGS, + add_ep, + create_ep, +) _LOGGER = logging.getLogger(__name__) @@ -78,8 +95,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 +111,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 +126,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,33 +197,85 @@ 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)) + 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_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: 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_number_esp32(config: ConfigType) -> ConfigType: + # 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) + ep = copy.deepcopy(ANALOG_OUTPUT_EP) + apptype = ANALOG_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_VALUE: (apptype << 16) | 0x0100FFFF, CONF_TYPE: "UINT32", }, ) @@ -203,22 +286,40 @@ def validate_sensor_esp32(config: ConfigType) -> ConfigType: 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 + + +def validate_switch_esp32(config: ConfigType) -> ConfigType: + if config[CONF_CLUSTER] == "on_off": + ep = copy.deepcopy(SWITCH_EP_CONFIGS["on_off"]) + if config.get(CONF_DEVICE_CLASS) == DEVICE_CLASS_OUTLET: + ep[DEVICE_TYPE] = "MAINS_POWER_OUTLET" + else: + if config.get(CONF_DEVICE_CLASS) == DEVICE_CLASS_OUTLET: + _LOGGER.warning( + "'device_class: outlet' has no effect with 'cluster: generic', " + "use 'cluster: on_off' to expose a MAINS_POWER_OUTLET device type" + ) + ep = copy.deepcopy(BINARY_OUTPUT_EP) setup_attributes(config, ep[CONF_CLUSTERS]) add_ep(ep, config.get(CONF_ENDPOINT), config.get(CONF_USE_DEVICE_TYPE)) return config @@ -273,21 +374,26 @@ 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( name="espressif/esp-zigbee-lib", ref="2.0.4", ) + # The esp-zigbee-lib blobs reference mbedtls_ecp_* (Zigbee Direct, install + # code ECDH); keep ECP without relying on esp_wifi's Kconfig select. + require_mbedtls_ecp() + + # 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 @@ -325,3 +431,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_event_esp32.h b/esphome/components/zigbee/zigbee_event_esp32.h new file mode 100644 index 00000000000..b8b3e97b4cf --- /dev/null +++ b/esphome/components/zigbee/zigbee_event_esp32.h @@ -0,0 +1,97 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#ifdef USE_ZIGBEE + +#include // for offsetof +#include // for memcpy +#include "esp_zigbee.h" +#include "ezbee/zha.h" + +namespace esphome::zigbee { + +class ZBEvent { + public: + // IMPORTANT: We MUST copy all values because the pointer from ESP-IDF + // is only valid during the callback execution. Since ZB events are processed + // asynchronously in the main loop, we store our own copy inline to ensure + // the data remains valid until the event is processed. + ZBEvent(ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute) + : event_{}, callback_id_(EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID) { + this->init_set_attr_value_data_(info, attribute); + } + + // Destructor to clean up heap allocations + ~ZBEvent() { this->release(); } + + // Default constructor for pre-allocation in pool + ZBEvent() : event_{}, callback_id_(EZB_ZCL_CORE_CB_ID_END) {} + + // Invoked on return to EventPool - clean up any heap-allocated data + void release() { + // Free any allocated memory within the event + switch (this->callback_id_) { + case EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID: + if (!this->event_.set_attr.is_inline && this->event_.set_attr.data.heap_data != nullptr) { + delete[] this->event_.set_attr.data.heap_data; + this->event_.set_attr.data.heap_data = nullptr; + } + break; + default: + break; + } + } + + // Load new event data for reuse (replaces previous event data) + // Note: release() is NOT called here because EventPool::release() already + // calls event->release() before returning to the free list. Every event + // from allocate() is already in a clean state. + void load_set_attr_value_event(ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute) { + this->callback_id_ = EZB_ZCL_CORE_SET_ATTR_VALUE_CB_ID; + this->init_set_attr_value_data_(info, attribute); + } + + // Disable copy to prevent double-delete + ZBEvent(const ZBEvent &) = delete; + ZBEvent &operator=(const ZBEvent &) = delete; + + union { + // NOLINTNEXTLINE(readability-identifier-naming) + struct set_attr_event { + ezb_zcl_message_info_t info; + ezb_zcl_attribute_t attribute; + union { + uint8_t *heap_data; + uint8_t inline_data[4]; // For small data types (<= 32 bit) + } data; + bool is_inline; + } set_attr; + } event_; + + ezb_zcl_core_action_callback_id_t callback_id_; + + private: + void init_set_attr_value_data_(ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute) { + this->event_.set_attr.info = info; + this->event_.set_attr.attribute = attribute; + // get attribute.data.value with correct type + if (attribute.data.value != nullptr) { + // Copy the attribute value to avoid dangling pointer issues + size_t value_size = ezb_zcl_get_attr_value_size(attribute.data.type, attribute.data.value); + if (value_size > 4) { + this->event_.set_attr.data.heap_data = new uint8_t[value_size]; + memcpy(this->event_.set_attr.data.heap_data, attribute.data.value, value_size); + this->event_.set_attr.attribute.data.value = this->event_.set_attr.data.heap_data; + this->event_.set_attr.is_inline = false; + } else { + memcpy(this->event_.set_attr.data.inline_data, attribute.data.value, value_size); + this->event_.set_attr.attribute.data.value = this->event_.set_attr.data.inline_data; + this->event_.set_attr.is_inline = true; + } + } + } +}; +} // namespace esphome::zigbee +#endif // USE_ZIGBEE +#endif // USE_ESP32 diff --git a/esphome/components/zigbee/zigbee_helpers_esp32.c b/esphome/components/zigbee/zigbee_helpers_esp32.c index 0793669955a..e52513b7408 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.c +++ b/esphome/components/zigbee/zigbee_helpers_esp32.c @@ -24,18 +24,76 @@ 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_MAINS_POWER_OUTLET_DEVICE_ID: { + ezb_zha_mains_power_outlet_config_t config = EZB_ZHA_MAINS_POWER_OUTLET_CONFIG(); + config.basic_cfg.power_source = power_source; + ep_desc = ezb_zha_create_mains_power_outlet(ep_id, &config); + break; + } + 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: return ezb_zcl_basic_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_IDENTIFY: return ezb_zcl_identify_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ON_OFF: + return ezb_zcl_on_off_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_TIME: return ezb_zcl_time_create_cluster_desc(NULL, role_mask); case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: return ezb_zcl_analog_input_create_cluster_desc(NULL, role_mask); + case EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT: + return ezb_zcl_analog_output_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_BINARY_OUTPUT: + return ezb_zcl_binary_output_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; @@ -51,12 +109,34 @@ ezb_err_t esphome_zb_cluster_add_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_ return ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_IDENTIFY: return ezb_zcl_identify_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ON_OFF: + return ezb_zcl_on_off_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_TIME: return ezb_zcl_time_cluster_desc_add_attr(cluster_desc, attr_id, value_p); case EZB_ZCL_CLUSTER_ID_ANALOG_INPUT: return ezb_zcl_analog_input_cluster_desc_add_attr(cluster_desc, attr_id, value_p); + case EZB_ZCL_CLUSTER_ID_ANALOG_OUTPUT: + return ezb_zcl_analog_output_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_BINARY_OUTPUT: + return ezb_zcl_binary_output_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.py b/esphome/components/zigbee/zigbee_zephyr.py index f47cf6bd40e..3f863140e69 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -314,18 +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) - - async def zephyr_setup_number( entity: cg.MockObj, config: ConfigType, @@ -404,7 +392,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 +404,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) @@ -433,7 +421,7 @@ async def _add_sensor(entity: cg.MockObj, config: ConfigType) -> None: ) -async def _add_switch(entity: cg.MockObj, config: ConfigType) -> None: +async def add_switch(entity: cg.MockObj, config: ConfigType) -> None: await _add_zigbee_ep( entity, config, diff --git a/esphome/components/zyaura/sensor.py b/esphome/components/zyaura/sensor.py index 0508f456e40..cfe980f037c 100644 --- a/esphome/components/zyaura/sensor.py +++ b/esphome/components/zyaura/sensor.py @@ -61,12 +61,7 @@ async def to_code(config: ConfigType) -> None: pin_data = await gpio_pin_expression(config[CONF_DATA_PIN]) cg.add(var.set_pin_data(pin_data)) - if CONF_CO2 in config: - sens = await sensor.new_sensor(config[CONF_CO2]) - cg.add(var.set_co2_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_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) diff --git a/esphome/config.py b/esphome/config.py index 13ec744ce45..28a688f15ab 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1129,6 +1129,42 @@ class IDPassValidationStep(ConfigValidationStep): if inherits: matches.append(v[0]) + if id.match_config: + # Disambiguate among same-type candidates by comparing their own + # declared config against the requested key/value pairs, e.g. an + # I2C address, instead of requiring a single unambiguous candidate. + criteria = ", ".join(f"{k}={v}" for k, v in id.match_config.items()) + filtered = [ + m + for m in matches + if isinstance( + candidate_conf := result.get_config_for_path( + result.get_path_for_id(m)[:-1] + ), + dict, + ) + and all( + candidate_conf.get(k) == v + for k, v in id.match_config.items() + ) + ] + if len(filtered) == 1: + id.id = filtered[0].id + elif len(filtered) == 0: + result.add_str_error( + f"Couldn't find a '{id.type}' matching {criteria}. " + "Are you missing a hub declaration, or is the address wrong?", + path, + ) + else: + ids = ", ".join(f"'{m.id}'" for m in filtered) + result.add_str_error( + f"Multiple '{id.type}' instances match {criteria}: {ids}. " + "You must assign an explicit ID to the one you want to use.", + path, + ) + continue + if len(matches) == 0: result.add_str_error( f"Couldn't find any component that can be used for '{id.type}'. Are you missing a hub declaration?", @@ -1226,6 +1262,7 @@ class CoreFinalValidateStep(ConfigValidationStep): with result.catch_error([CONF_ESPHOME]): if CONF_ESPHOME in result: core_config.validate_ids_and_references(result[CONF_ESPHOME]) + core_config.validate_loop_interval(result[CONF_ESPHOME]) fv.full_config.reset(token) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 1623117a367..579da9a315e 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -2354,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 5ffbf8c49ac..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): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 5fcad90a81a..1668263e337 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 @@ -385,7 +385,9 @@ class Lambda: class ID: - def __init__(self, id, is_declaration=False, type=None, is_manual=None): + def __init__( + self, id, is_declaration=False, type=None, is_manual=None, match_config=None + ): self.id = id if is_manual is None: self.is_manual = id is not None @@ -393,6 +395,10 @@ class ID: self.is_manual = is_manual self.is_declaration = is_declaration self.type: MockObjClass | None = type + # When set, an unnamed (id=None) searching ID is disambiguated among same-type + # candidates by matching these key/value pairs against each candidate's own + # declared config, instead of requiring exactly one candidate to exist. + self.match_config: dict | None = match_config def resolve(self, registered_ids): from esphome.config_validation import RESERVED_IDS @@ -431,6 +437,7 @@ class ID: is_declaration=self.is_declaration, type=self.type, is_manual=self.is_manual, + match_config=self.match_config, ) @@ -589,6 +596,8 @@ class EsphomeCore: self.vscode = False # True if running in testing mode (disables validation checks for grouped testing) self.testing_mode = False + # True if this build skips the bootloader and factory image (OTA only) + self.skip_bootloader = False # The name of the node self.name: str | None = None # The friendly name of the node @@ -692,6 +701,7 @@ class EsphomeCore: from esphome.pins import PIN_SCHEMA_REGISTRY self.dashboard = False + self.skip_bootloader = False self.name = None self.friendly_name = None self.area = None @@ -996,6 +1006,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 @@ -1209,10 +1225,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 50d1c619595..2ddc97efb5f 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -36,7 +36,7 @@ namespace esphome { -static const char *const TAG = "app"; +ESPHOME_LOG_TAG(TAG, "app"); // Delay after setup() finishes before trimming the scheduler freelist of its post-boot peak. // 10 s is well past the bulk of post-setup async work (Wi-Fi/MQTT connects, first-read latency). diff --git a/esphome/core/application.h b/esphome/core/application.h index 8ed4c09096a..2f399cbdcd5 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -208,8 +208,8 @@ class Application { * Each component can request a high frequency loop execution by using the HighFrequencyLoopRequester * helper in helpers.h * - * Note: This method is not called by ESPHome core code. It is only used by lambda functions - * in YAML configurations or by external components. + * Sleep per wake is capped at 2 * WDT_FEED_INTERVAL_MS (except host and ESP8266); + * raise the platform watchdog timeout to sleep longer. * * @param loop_interval The interval in milliseconds to run the core loop at. Defaults to 16 milliseconds. */ @@ -232,6 +232,7 @@ class Application { /// - ESP8266 soft WDT (~1.6 s): ~16x <-- 100 ms feed (see USE_ESP8266 below) /// - ESP8266 HW WDT (~6 s): ~60x /// - BK72xx HW WDT (10 s): ~5x <-- platform override below + /// Important: if these are modified align validate_loop_interval in config.py #ifdef USE_BK72XX // BDK busy-waits 200us per WDT reload (sctrl_dpll_delay200us). LibreTiny // sets HW WDT to 10s; 2000ms keeps ~5x margin. See wdt_ctrl WCMD_RELOAD_PERIOD: @@ -775,8 +776,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { } #endif - // Compute sleep: bounded by time-until-next-component-phase and the - // scheduler's next deadline. When a scheduler timer fires it re-enters + // Compute sleep: bounded by time-until-next-component-phase if there are + // components with loop enabled and the scheduler's next deadline. When a scheduler timer fires it re-enters // loop(), Phase A services it, and the component phase stays gated by // loop_interval_. When a background producer calls wake_loop_threadsafe() // it sets the wake_request flag and wakes select() / the task notification; @@ -795,16 +796,47 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { uint32_t delay_time = 0; if (!HighFrequencyLoopRequester::is_high_frequency()) { const uint32_t elapsed_since_phase = now - this->last_loop_; - const uint32_t until_phase = +#ifdef ESPHOME_SUSPEND_LOOP + const bool has_loop_work = + this->looping_components_active_end_ > 0 || this->dump_config_at_ < this->components_.size(); + uint32_t until_phase = std::numeric_limits::max(); + if (has_loop_work) { + until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); + } +#else + uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase); +#endif const uint32_t until_sched = this->scheduler.next_schedule_in(now).value_or(until_phase); delay_time = std::min(until_phase, until_sched); } // All platforms route loop yields through the platform wake primitive. // On host this drains the loopback wake socket via select(); on FreeRTOS // targets it uses task notifications; on ESP8266/RP2040 it uses esp_delay/WFE. - esphome::internal::wakeable_delay(delay_time); + // Cap the sleep so the WDT feed and status-LED dispatch rate limits still get + // exercised even when loop_interval is raised or the scheduler and component + // phases are gated out for a long sleep. Waking every 2*WDT_FEED_INTERVAL_MS + // clears the feed rate limit on every wake, so the WDT is fed at least that + // often -- well inside every platform's timeout. +#if defined(USE_ESP8266) + // SDK os_timer_arm() accepts at most 0x68D7A3 ms without system_timer_reinit(); + // the SDK feeds both watchdogs while the cont task is suspended, so no WDT cap needed. + static constexpr uint32_t MAX_SLEEP_BASE = 0x68D7A3; +#elif defined(USE_HOST) + // arch_feed_wdt() is a no-op on host and ESPHOME_SUSPEND_LOOP is rejected by + // the config validator, so delay_time is already bounded by loop_interval_. + static constexpr uint32_t MAX_SLEEP_BASE = std::numeric_limits::max(); +#else + static constexpr uint32_t MAX_SLEEP_BASE = WDT_FEED_INTERVAL_MS * 2; +#endif + uint32_t max_sleep = MAX_SLEEP_BASE; +#ifdef USE_STATUS_LED + if ((this->app_state_ & STATUS_LED_MASK) != 0) { + max_sleep = std::min(max_sleep, STATUS_LED_DISPATCH_INTERVAL_MS); + } +#endif + esphome::internal::wakeable_delay(std::min(delay_time, max_sleep)); if (this->dump_config_at_ < this->components_.size()) { this->process_dump_config_(); } diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 5f010521dce..d3f1fe59e59 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -69,6 +69,70 @@ template class TemplatableFn { T (*f_)(X...){nullptr}; }; +/// Byte payload that is either a stateless lambda or a static table, which may be in PROGMEM. +/// 8 bytes on 32-bit; codegen stores constant payloads as shared flash tables. +template class TemplatableBytes { + public: + void set_template(std::vector (*func)(Ts...)) { + this->code_.func = func; + this->len_ = -1; + } + void set_static(const uint8_t *data, uint16_t len) { + this->code_.data = data; + this->len_ = len; + } + bool is_static() const { return this->len_ >= 0; } + /// Only valid when is_static(); may point to PROGMEM, so read it with progmem_memcpy or progmem_read_byte. + const uint8_t *data() const { return this->code_.data; } + /// Only valid when is_static(). + size_t size() const { return static_cast(this->len_); } + std::vector value(const Ts &...x) const { + if (this->len_ < 0) + return this->code_.func(x...); + return to_vector(this->code_.data, this->size()); + } + /// Calls fn(const uint8_t *data, size_t len) with the payload readable from RAM: a lambda's vector, a static + /// table directly, or on ESP8266 a copy of the PROGMEM table (on the stack up to N bytes). + template void visit(F &&fn, const Ts &...x) const { + if (this->len_ < 0) { + const std::vector bytes = this->code_.func(x...); + fn(bytes.data(), bytes.size()); + return; + } +#ifdef USE_ESP8266 + SmallBufferWithHeapFallback buf(this->size()); + if (this->len_ != 0) + progmem_memcpy(buf.get(), this->code_.data, this->size()); + fn(buf.get(), this->size()); +#else + fn(this->code_.data, this->size()); +#endif + } + + protected: + static std::vector to_vector(const uint8_t *data, size_t len) { + std::vector out(len); + // An empty payload is (nullptr, 0), and memcpy from nullptr is undefined even for zero bytes. + if (len != 0) + progmem_memcpy(out.data(), data, len); // byte loads from flash fault on ESP8266 + return out; + } + + union { + std::vector (*func)(Ts...); + const uint8_t *data; + } code_{}; + int32_t len_{-1}; // -1: lambda, otherwise the length of the static table +}; + +#define TEMPLATABLE_BYTES(name) \ + protected: \ + TemplatableBytes name##_{}; \ +\ + public: \ + void set_##name##_template(std::vector (*func)(Ts...)) { this->name##_.set_template(func); } \ + void set_##name##_static(const uint8_t *data, uint16_t len) { this->name##_.set_static(data, len); } + // Forward declaration for TemplatableValue (string specialization needs it) template class TemplatableValue; @@ -317,13 +381,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: @@ -346,10 +405,14 @@ template class TemplatableValue { /// Check if this holds a static string (const char* stored without allocation) /// The pointer is always directly readable (RAM or flash-mapped). /// Returns false for FLASH_STRING (PROGMEM on ESP8266, requires _P functions). + // Remove before 2027.4.0 + ESPDEPRECATED("Read the value with ref_or_copy_to() instead. Removed in 2027.4.0", "2026.10.0") bool is_static_string() const { return this->type_ == STATIC_STRING; } /// Get the static string pointer (only valid if is_static_string() returns true) /// The pointer is always directly readable — FLASH_STRING uses a separate type. + // Remove before 2027.4.0 + ESPDEPRECATED("Use ref_or_copy_to() instead. Removed in 2027.4.0", "2026.10.0") const char *get_static_string() const { return this->static_str_; } /// Check if the string value is empty without allocating. @@ -491,7 +554,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 +562,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. diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 999b38bd5cf..8762cbe7110 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include namespace esphome { @@ -252,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/component.cpp b/esphome/core/component.cpp index 41dd32ea665..0aa2dc21a24 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -12,7 +12,7 @@ namespace esphome { -static const char *const TAG = "component"; +ESPHOME_LOG_TAG(TAG, "component"); // Global vectors for component data that doesn't belong in every instance. // Using vector instead of unordered_map for both because: 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 8a4eb0fc379..531a0276471 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -7,7 +7,10 @@ from pathlib import Path from esphome import automation, core import esphome.codegen as cg -from esphome.config_helpers import filter_source_files_from_platform +from esphome.config_helpers import ( + filter_source_files_from_defines, + filter_source_files_from_platform, +) import esphome.config_validation as cv from esphome.const import ( CONF_AREA, @@ -40,16 +43,21 @@ from esphome.const import ( CONF_PROJECT, CONF_TRIGGER_ID, CONF_VERSION, + CONF_WATCHDOG_TIMEOUT, KEY_CORE, + PLATFORM_ESP32, + PLATFORM_HOST, + PLATFORM_RP2, PlatformFramework, __version__ as ESPHOME_VERSION, ) from esphome.core import ( CORE, - KEY_CONTROLLER_REGISTRY_COUNT, + KEY_CONTROLLER_REGISTRY_CONTROLLERS, CoroPriority, coroutine_with_priority, ) +import esphome.final_validate as fv from esphome.helpers import ( copy_file_if_changed, cpp_string_escape, @@ -60,6 +68,9 @@ from esphome.helpers import ( ) from esphome.types import ConfigType +CONF_LOOP_INTERVAL = "loop_interval" +CONF_SUSPEND_LOOP = "suspend_loop" + _LOGGER = logging.getLogger(__name__) # C++ variable names and separators for app name buffers (used with MAC suffix) @@ -183,6 +194,35 @@ def validate_ids_and_references(config: ConfigType) -> ConfigType: return config +def validate_loop_interval(config: ConfigType) -> ConfigType: + if CONF_LOOP_INTERVAL in config and not (CORE.is_host or CORE.is_esp8266): + # max_loop interval is calculated from WDT_FEED_INTERVAL_MS. Make sure to align with application.h + max_loop = 600 # 2 * 300ms default for other platforms + if CORE.is_esp32: + max_loop = ( + 2 + * fv.full_config.get()[PLATFORM_ESP32][ + CONF_WATCHDOG_TIMEOUT + ].total_milliseconds + // 5 + ) + elif CORE.is_bk72xx: + max_loop = 4000 # 10000ms / 5 * 2 default value + if config[CONF_LOOP_INTERVAL].total_milliseconds > max_loop: + _LOGGER.warning( + "%s of %s exceeds the %sms maximum sleep on this platform; the loop will still " + "wake every %sms.%s", + CONF_LOOP_INTERVAL, + config[CONF_LOOP_INTERVAL], + max_loop, + max_loop, + " Raise esp32.watchdog_timeout to sleep longer." + if CORE.is_esp32 + else "", + ) + return config + + def valid_include(value: str) -> str: # Look for "<...>" includes if value.startswith("<") and value.endswith(">"): @@ -263,6 +303,15 @@ def validate_area_config(config: dict | str) -> dict[str, str | core.ID]: return cv.maybe_simple_value(AREA_SCHEMA, key=CONF_NAME)(config) +def _validate_suspend_loop(value: bool) -> bool: + # host and RP2 platforms have unwakeable delay fallbacks, so suspending the main loop is unsafe + if value and CORE.target_platform in [PLATFORM_HOST, PLATFORM_RP2]: + raise cv.Invalid( + f"Suspend loop is not available on {CORE.target_platform} platform" + ) + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -328,6 +377,19 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_DEBUG_SCHEDULER, default=False, visibility=cv.Visibility.YAML_ONLY ): cv.boolean, + cv.Optional(CONF_LOOP_INTERVAL, visibility=cv.Visibility.YAML_ONLY): cv.All( + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriod(milliseconds=1), + max=cv.TimePeriod(milliseconds=65535), + ), + ), + cv.Optional( + CONF_SUSPEND_LOOP, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.All( + cv.boolean, + _validate_suspend_loop, + ), cv.Optional(CONF_PROJECT): cv.Schema( { cv.Required(CONF_NAME): cv.All( @@ -549,13 +611,21 @@ def _add_library_str(lib: str) -> None: # platformio_options keys the native ESP8266 Arduino generator (a later PR # in this chain) will honor; its ignored-option warning will consume the same # list so the two cannot drift -NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscript"}) +NATIVE_ARDUINO_PIO_OPTIONS = frozenset( + {"board_build.f_cpu", "board_build.ldscript", "board_upload.maximum_size"} +) # The full set that survives into CORE.platformio_options under the native # arduino toolchain: lib_ignore is the only specially-translated key below # 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) @@ -603,9 +673,13 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No # through to the ignored-option warning). Other native # toolchains have no equivalent and fall through too. cg.add_platformio_option(key, vals[-1]) - elif key != "upload_speed": - # upload_speed needs no handling: it is read from the raw - # config at upload time (upload_using_esptool) + elif key != "upload_speed" and not ( + key == "board_upload.flash_size" and CORE.using_toolchain_arduino + ): + # upload_speed is read from the raw config at upload time. + # board_upload.flash_size is dropped silently on arduino: + # PlatformIO's esp8266 builder never reads it either, and + # published configs (Athom) commonly set it. _LOGGER.warning( "esphome->platformio_options->%s is ignored when building with " "the native '%s' toolchain", @@ -674,12 +748,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) @@ -755,7 +839,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) @@ -769,6 +853,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 @@ -780,6 +865,10 @@ async def to_code(config: ConfigType) -> None: cg.add_cxx_build_flag("-Wno-volatile") if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") + if config[CONF_SUSPEND_LOOP]: + cg.add_define("ESPHOME_SUSPEND_LOOP") + if CONF_LOOP_INTERVAL in config: + cg.add(cg.App.set_loop_interval(config[CONF_LOOP_INTERVAL])) if CORE.using_arduino: CORE.add_job(add_arduino_global_workaround) @@ -874,7 +963,7 @@ async def to_code(config: ConfigType) -> None: # Platform-specific source files for core -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_filter_platform_sources = filter_source_files_from_platform( { "static_task.cpp": { PlatformFramework.ESP32_ARDUINO, @@ -927,3 +1016,11 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( # as they are only included when needed by the preprocessor } ) + +# util.cpp holds only the out-of-line MQTT connectivity helpers; without +# USE_MQTT the header provides inline stubs and the file must not compile. +_filter_define_sources = filter_source_files_from_defines({"util.cpp": "USE_MQTT"}) + + +def FILTER_SOURCE_FILES() -> list[str]: + return _filter_platform_sources() + _filter_define_sources() 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 e51928f8d46..127c83f934b 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -77,6 +77,7 @@ #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 @@ -85,10 +86,15 @@ #define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF +#define USE_IR_RF_TRANSMIT_COMPLETE #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 @@ -137,6 +143,12 @@ #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 @@ -174,6 +186,7 @@ #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 @@ -243,6 +256,7 @@ #define AUDIO_FILE_MAX_FILES 4 #define USE_AUDIO_DAC #define USE_AUDIO_FLAC_SUPPORT +#define USE_AUDIO_HTTP_CA_CERTIFICATE #define USE_AUDIO_MP3_SUPPORT #define USE_AUDIO_OPUS_SUPPORT #define USE_AUDIO_WAV_SUPPORT @@ -254,7 +268,14 @@ #define USE_API_HOMEASSISTANT_SERVICES #define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE +#define USE_API_OUTGOING_CONNECTION +#define API_OUTGOING_CONNECTION_PORT 6054 +#define API_OUTGOING_CONNECTION_DELAY 60000 #define USE_API_VARINT64 +#define USE_API_WIZARD +#define USE_API_WIZARD_INPUTS +#define USE_API_WIZARD_LINKED_INPUTS +#define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES @@ -264,12 +285,15 @@ #define USE_STORE_YAML #define API_MAX_SEND_QUEUE 8 #define API_USER_ACTION_STRINGS_SCRATCH_SIZE 64 +#define API_WIZARD_DATA_SIZE 1 +#define API_WIZARD_INPUT_COUNT 1 #define MAX_API_CONNECTIONS 6 // The Improv library is not in the Zephyr tidy environment #define USE_IMPROV_SERIAL #define USE_IMPROV_NEXT_URL #define USE_MD5 #define USE_NOISE +#define USE_NOISE_SPARE_EPHEMERAL #define USE_SHA256 #ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 #define USE_MQTT @@ -283,8 +307,8 @@ #define USE_RUNTIME_IMAGE_QOI #define USE_RUNTIME_STATS #define USE_OTA +#define USE_OTA_DEFLATE #define USE_OTA_ENCRYPTION -#define USE_OTA_ENCRYPTION_FROM_API #define USE_OTA_ENCRYPTION_PROVISIONED #define USE_OTA_ENCRYPTION_REQUIRED #define USE_OTA_PASSWORD @@ -378,6 +402,7 @@ #define USE_ESP32_BLE_SERVER_ON_CONNECT #define USE_ESP32_BLE_SERVER_ON_DISCONNECT #define USE_ESP32_BLE_TRACKER +#define USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD #define USE_BLE_GATT_CLIENT #define ESPHOME_BLE_GATT_CLIENT_COUNT 1 #define ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT 1 @@ -390,6 +415,7 @@ #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 +#define ESPHOME_SUSPEND_LOOP #define USE_ESP32_CAMERA_JPEG_ENCODER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -405,6 +431,10 @@ #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS +#define USE_SOCKET_IPV4_ALLOW +#define USE_SOCKET_IPV4_RESOLVE +#define USE_SOCKET_TCP_CLIENT_LINK +#define USE_SOCKET_TCP_LISTENER #define USE_LWIP_FAST_SELECT #define USE_SPEAKER @@ -441,6 +471,15 @@ #define USB_HOST_MAX_REQUESTS 16 #define USB_HOST_MAX_PACKET_SIZE 64 #define USB_UART_OUTPUT_CHUNK_COUNT 5 +#define USE_USB_UART_CH34X +#define USE_USB_UART_CP210X +#define USE_USB_UART_FT23XX +#define USE_USB_UART_PL2303 +// USB identity on serial proxy ports needs the usb_host stack +#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) +#define USE_SERIAL_PROXY_USB_IDENTITY +#endif #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 7) @@ -463,14 +502,17 @@ #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 #define USE_ETHERNET_CONNECT_TRIGGER #define USE_ETHERNET_DISCONNECT_TRIGGER #define ESPHOME_ETHERNET_IP_STATE_LISTENERS 2 +#define ESPHOME_ETHERNET_PHY_REGISTER_COUNT 2 #endif #ifdef USE_ESP32 @@ -480,6 +522,10 @@ #define USE_OPENTHREAD #define USE_ZIGBEE #endif +#if defined(USE_ESP32_VARIANT_ESP32) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#define USE_ESPECTRE +#endif #ifndef USE_OPENTHREAD #define USE_MDNS_SUPPORTS_ENABLE_DISABLE #endif @@ -503,6 +549,7 @@ // ESP8266-specific feature flags #ifdef USE_ESP8266 +#define ESPHOME_SUSPEND_LOOP #define USE_ADC_SENSOR_VCC #define USE_ESP8266_CRASH_HANDLER #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 1, 2) @@ -538,7 +585,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, 1, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 1) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -576,10 +623,12 @@ // to parse against. #ifdef USE_LN882X #define USE_LN882H_BLE_TRACKER +#define USE_WIFI_AP_EXCLUSIVE #else #define USE_BK72XX_BLE_TRACKER #endif #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 +#define ESPHOME_SUSPEND_LOOP #define USE_BLE_SCAN_RESPONSE_MERGER #define USE_CAPTIVE_PORTAL #define USE_WIFI_SCAN_RESULTS_LOCK @@ -605,6 +654,7 @@ #ifdef USE_NRF52 #define ESPHOME_BLE_NUS_TX_RING_BUFFER_SIZE 512 #define ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE 512 +#define ESPHOME_SUSPEND_LOOP #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 #define USE_LOGGER_EARLY_MESSAGE @@ -615,6 +665,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 @@ -630,7 +681,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 dc27c1e56a2..cbb14a37302 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -1,12 +1,13 @@ #include "esphome/core/entity_base.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/progmem.h" #include "esphome/core/string_ref.h" namespace esphome { -static const char *const TAG = "entity_base"; +ESPHOME_LOG_TAG(TAG, "entity_base"); void EntityBase::configure_entity_(const char *name, uint32_t object_id_hash, uint32_t entity_fields) { this->name_ = StringRef(name); 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/freertos_queue.h b/esphome/core/freertos_queue.h index 2f3faf818a3..1067ae07ccf 100644 --- a/esphome/core/freertos_queue.h +++ b/esphome/core/freertos_queue.h @@ -27,9 +27,7 @@ namespace esphome { template class FreeRTOSQueue { public: - FreeRTOSQueue() : dropped_count_(0) { - this->handle_ = xQueueCreateStatic(SIZE, sizeof(T *), this->storage_, &this->queue_buf_); - } + FreeRTOSQueue() : dropped_count_(0) { xQueueCreateStatic(SIZE, sizeof(T *), this->storage_, &this->queue_buf_); } // No destructor — ESPHome components are never destroyed. Intentionally // omitted to avoid pulling in vQueueDelete code on resource-constrained targets. @@ -44,7 +42,7 @@ template class FreeRTOSQueue { if (element == nullptr) return false; - if (xQueueSend(this->handle_, &element, 0) != pdPASS) { + if (xQueueSend(this->handle_(), &element, 0) != pdPASS) { this->increment_dropped_count(); return false; } @@ -53,7 +51,7 @@ template class FreeRTOSQueue { T *pop() { T *element; - if (xQueueReceive(this->handle_, &element, 0) != pdTRUE) { + if (xQueueReceive(this->handle_(), &element, 0) != pdTRUE) { return nullptr; } return element; @@ -80,18 +78,24 @@ template class FreeRTOSQueue { portEXIT_CRITICAL(); } - bool empty() const { return uxQueueMessagesWaiting(this->handle_) == 0; } + bool empty() const { return uxQueueMessagesWaiting(this->handle_()) == 0; } - bool full() const { return uxQueueSpacesAvailable(this->handle_) == 0; } + bool full() const { return uxQueueSpacesAvailable(this->handle_()) == 0; } - size_t size() const { return uxQueueMessagesWaiting(this->handle_); } + size_t size() const { return uxQueueMessagesWaiting(this->handle_()); } protected: // Static storage for the queue — lives in BSS, no heap allocation uint8_t storage_[SIZE * sizeof(T *)]; StaticQueue_t queue_buf_; - QueueHandle_t handle_; uint16_t dropped_count_; + + // xQueueCreateStatic() returns the static queue structure itself as the handle; + // its only other checks are asserts, and a zero length is the one we could hit + static_assert(SIZE > 0, "FreeRTOSQueue needs at least one slot"); + QueueHandle_t handle_() const { + return reinterpret_cast(const_cast(&this->queue_buf_)); + } }; } // namespace esphome diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 433d2547b0e..6130f7e27df 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -20,7 +19,16 @@ namespace esphome { -static const char *const TAG = "helpers"; +#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 + +ESPHOME_LOG_TAG(TAG, "helpers"); __attribute__((noinline, cold)) void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, size_t elem_size) { @@ -612,15 +620,11 @@ int8_t step_to_accuracy_decimals(float step) { return decimals; } -// Map a base64/base64url character to its 6-bit value (0-63) arithmetically. -// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there). -// Supports both standard base64 (+/) and base64url (-_) alphabets. -// NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters. -// This is safe because is_base64() is ALWAYS checked before calling this function, -// preventing invalid characters from ever reaching here. The base64_decode function -// stops processing at the first invalid character due to the is_base64() check in its -// while loop condition, making this edge case harmless in practice. -static inline uint8_t base64_find_char(char c) { +static constexpr uint8_t INVALID_BASE64_CHAR = 0xFF; + +// 6-bit value of a base64 or base64url char, or INVALID_BASE64_CHAR. +// No lookup table: .rodata lives in DRAM on ESP8266. +static constexpr uint8_t base64_char_value(uint8_t c) { if (c >= 'A' && c <= 'Z') return c - 'A'; if (c >= 'a' && c <= 'z') @@ -632,70 +636,35 @@ static inline uint8_t base64_find_char(char c) { return 62; if (c == '/' || c == '_') return 63; - return 0; + return INVALID_BASE64_CHAR; } -// Check if character is valid base64 or base64url -static inline bool is_base64(char c) { return (isalnum(c) || (c == '+') || (c == '/') || (c == '-') || (c == '_')); } - // base64_encode (both overloads) moved to alloc_helpers.cpp size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { return base64_decode(reinterpret_cast(encoded_string.data()), encoded_string.size(), buf, buf_len); } -// Decode 4 base64 characters to up to 'count' output bytes, returns true if truncated. -static inline bool base64_decode_quad(uint8_t *char_array_4, int count, uint8_t *buf, size_t buf_len, size_t &out) { - for (int i = 0; i < 4; i++) - char_array_4[i] = base64_find_char(char_array_4[i]); - - uint8_t char_array_3[3]; - char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4); - char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); - char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - - bool truncated = false; - for (int j = 0; j < count; j++) { - if (out < buf_len) { - buf[out++] = char_array_3[j]; - } else { - truncated = true; - } - } - return truncated; -} - size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len) { - size_t in_len = encoded_len; - int i = 0; - size_t in = 0; size_t out = 0; - uint8_t char_array_4[4]; - bool truncated = false; - - // SAFETY: The loop condition checks is_base64() before processing each character. - // This ensures base64_find_char() is only called on valid base64 characters, - // preventing the edge case where invalid chars would return 0 (same as 'A'). - while (in_len-- && (encoded_data[in] != '=') && is_base64(encoded_data[in])) { - char_array_4[i++] = encoded_data[in]; - in++; - if (i == 4) { - truncated |= base64_decode_quad(char_array_4, 3, buf, buf_len, out); - i = 0; + uint32_t accum = 0; + uint32_t bits = 0; + // Stops at '=' or any non-alphabet char; leftover bits of a partial group are dropped. + for (size_t in = 0; in < encoded_len; in++) { + uint8_t value = base64_char_value(encoded_data[in]); + if (value == INVALID_BASE64_CHAR) + break; + accum = (accum << 6) | value; + bits += 6; + if (bits >= 8) { + bits -= 8; + if (out == buf_len) { + ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); + return out; + } + buf[out++] = static_cast(accum >> bits); } } - - if (i) { - for (int j = i; j < 4; j++) - char_array_4[j] = 0; - - truncated |= base64_decode_quad(char_array_4, i - 1, buf, buf_len, out); - } - - if (truncated) { - ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); - } - return out; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index cfc92932a9d..b88a9d70e93 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -129,23 +129,82 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); /// @name Container utilities ///@{ -/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory) -/// Avoids copying data from flash to RAM by keeping a pointer to the flash data. -/// Similar to std::span but with minimal overhead for embedded systems. - -template class ConstVector { +/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory). +/// Iterators are raw pointers like FixedVector. With Owning = true it can also hold a heap copy it +/// owns (see the specialization below); the default view never frees and has no extra cost. +template class ConstVector { public: + using value_type = T; + + constexpr ConstVector() = default; constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} - const constexpr T &operator[](size_t i) const { return data_[i]; } - constexpr size_t size() const { return size_; } - constexpr bool empty() const { return size_ == 0; } + const T *begin() const { return this->data_; } + const T *end() const { return this->data_ + this->size_; } + const T *data() const { return this->data_; } + constexpr size_t size() const { return this->size_; } + constexpr bool empty() const { return this->size_ == 0; } + const constexpr T &operator[](size_t i) const { return this->data_[i]; } + const T &at(size_t i) const { return this->data_[i]; } protected: - const T *data_; - size_t size_; + const T *data_{nullptr}; + size_t size_{0}; }; +/// Owning variant: a codegen table that outlives it, or a heap copy of a runtime list it owns. +/// Ownership is the top bit of the size; it is not copyable, so a copy can never outlive the owner. +/// Elements must be whole words so ESP8266 can read a codegen table from flash. +template class ConstVector { + static_assert(std::is_trivially_copyable_v && sizeof(T) % sizeof(uint32_t) == 0, + "ConstVector elements must be whole words so ESP8266 can read them from flash"); + + public: + using value_type = T; + + constexpr ConstVector() = default; + constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} + ConstVector(const ConstVector &) = delete; + ConstVector &operator=(const ConstVector &) = delete; + ~ConstVector() { this->release_(); } + + const T *begin() const { return this->data_; } + const T *end() const { return this->data_ + this->size(); } + const T *data() const { return this->data_; } + size_t size() const { return this->size_ & ~OWNED_BIT; } + bool empty() const { return this->size() == 0; } + const T &operator[](size_t index) const { return this->data_[index]; } + const T &at(size_t index) const { return this->data_[index]; } + + /// Codegen only: call before any runtime copy; it does not free a previous owned copy + /// (generated setup() runs before any lambda or automation can call set_options). + void assign_static(const T *data, size_t size) { + this->data_ = data; + this->size_ = size; + } + /// Copies the list into a heap array this owns, freeing a previous owned copy. + void assign_copy(const T *data, size_t size) { + auto *table = new T[size]; // NOLINT(cppcoreguidelines-owning-memory) + std::copy(data, data + size, table); + this->release_(); + this->data_ = table; + this->size_ = size | OWNED_BIT; + } + + protected: + static constexpr size_t OWNED_BIT = size_t{1} << (sizeof(size_t) * 8 - 1); + + void release_() { + if (this->size_ & OWNED_BIT) + delete[] this->data_; // NOLINT(cppcoreguidelines-owning-memory) + } + + const T *data_{nullptr}; + size_t size_{0}; // top bit set when data_ is an owned heap copy +}; +static_assert(sizeof(ConstVector) == 2 * sizeof(void *), + "ConstVector must stay a pointer and a size"); + /// Small buffer optimization - stores data inline when small, heap-allocates for large data /// This avoids heap fragmentation for common small allocations while supporting arbitrary sizes. /// Memory management is encapsulated - callers just use set() and data(). @@ -276,6 +335,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) { @@ -2066,6 +2128,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 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..145ba3a6f8a 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)) @@ -189,4 +190,12 @@ struct LogString; #endif +// Declares a static log tag at namespace scope in a .cpp file; on ESP8266 it lives in flash, so read it with +// the _P functions, never strcmp/strlen. +#ifdef USE_ESP8266 +#define ESPHOME_LOG_TAG(name, tag) static const char name[] PROGMEM = tag +#else +#define ESPHOME_LOG_TAG(name, tag) static constexpr const char *const name = tag +#endif + } // namespace esphome 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/scheduler.cpp b/esphome/core/scheduler.cpp index afb323f78ef..5acd2eec273 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -12,7 +12,7 @@ namespace esphome { -static const char *const TAG = "scheduler"; +ESPHOME_LOG_TAG(TAG, "scheduler"); // Maximum number of logically deleted (cancelled) items before forcing cleanup. // Empirically chosen to balance cleanup overhead against tombstone accumulation in items_. diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 56fc83f12fd..4911bdde74f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -518,7 +518,8 @@ class Scheduler { uint32_t hash_or_id, SchedulerItem::Type type, bool find_first); - Mutex lock_; + // Empty on single-threaded platforms (ESP8266, RP2), so it takes no space there + [[no_unique_address]] Mutex lock_; std::vector items_; std::vector to_add_; diff --git a/esphome/core/static_task.cpp b/esphome/core/static_task.cpp index 43011083159..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; @@ -65,13 +72,9 @@ bool StaticTask::deallocate() { if (!this->destroy()) { return false; } - 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; - } + this->stack_buffer_.reset(); + this->stack_size_ = 0; + this->tcb_.reset(); return true; } diff --git a/esphome/core/static_task.h b/esphome/core/static_task.h index e2996abedae..10c55f70c62 100644 --- a/esphome/core/static_task.h +++ b/esphome/core/static_task.h @@ -5,49 +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 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, keeping the stack buffer allocated for reuse by a subsequent create() call. + /// @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 created) and free the stack buffer. - /// @return true if the stack buffer was freed; false if the task is still running on another core, in + /// @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/core/time_64.cpp b/esphome/core/time_64.cpp index 25076228d5e..1b0a49acaf7 100644 --- a/esphome/core/time_64.cpp +++ b/esphome/core/time_64.cpp @@ -17,7 +17,7 @@ namespace esphome { #ifdef ESPHOME_DEBUG_SCHEDULER -static const char *const TAG = "time_64"; +ESPHOME_LOG_TAG(TAG, "time_64"); #endif #ifdef ESPHOME_THREAD_SINGLE diff --git a/esphome/core/util.cpp b/esphome/core/util.cpp index 54a7956163a..4bca130e4a7 100644 --- a/esphome/core/util.cpp +++ b/esphome/core/util.cpp @@ -1,23 +1,16 @@ #include "esphome/core/util.h" -#include "esphome/core/application.h" -#include "esphome/core/version.h" -#include "esphome/core/log.h" +// Compiled only on MQTT builds (core FILTER_SOURCE_FILES); without USE_MQTT +// the header provides inline stubs and this file must stay empty, so the +// guard protects builds that compile every source, such as clang-tidy. #ifdef USE_MQTT #include "esphome/components/mqtt/mqtt_client.h" -#endif namespace esphome { -bool mqtt_is_connected() { -#ifdef USE_MQTT - if (mqtt::global_mqtt_client != nullptr) { - return mqtt::global_mqtt_client->is_connected(); - } -#endif - return false; -} +bool mqtt_is_connected() { return mqtt::global_mqtt_client != nullptr && mqtt::global_mqtt_client->is_connected(); } bool remote_is_connected() { return api_is_connected() || mqtt_is_connected(); } } // namespace esphome +#endif diff --git a/esphome/core/util.h b/esphome/core/util.h index 8f90aa34119..aa3098ba652 100644 --- a/esphome/core/util.h +++ b/esphome/core/util.h @@ -25,9 +25,17 @@ ESPHOME_ALWAYS_INLINE inline bool api_is_connected() { return false; } #endif /// Return whether the node has an active connection to an MQTT broker +#ifdef USE_MQTT bool mqtt_is_connected(); +#else +inline bool mqtt_is_connected() { return false; } +#endif /// Return whether the node has any form of "remote" connection via the API or to an MQTT broker +#ifdef USE_MQTT bool remote_is_connected(); +#else +inline bool remote_is_connected() { return api_is_connected(); } +#endif } // namespace esphome diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 173002438ad..267e86c2edf 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -19,7 +19,12 @@ 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, + ensure_unique_string, + 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 @@ -463,6 +468,53 @@ def progmem_array(id_, rhs) -> "MockObj": return obj +class ExternProgmemAssignmentExpression(ProgmemAssignmentExpression): + __slots__ = () + + def __str__(self): + return f"const {self.type} {self.name}[] PROGMEM = {self.rhs}" + + +def extern_progmem_array( + qualified_name: str, type_: "MockObjClass", rhs: SafeExpType +) -> "MockObj": + """Emit an externally linked PROGMEM table that a component declares extern and reads itself.""" + CORE.add_global( + ExternProgmemAssignmentExpression(type_, qualified_name, safe_exp(rhs)) + ) + return MockObj(qualified_name, ".") + + +def shared_progmem_array( + name: str, type_: "MockObjClass", rhs: SafeExpType, *, share: bool = True +) -> "MockObj": + """Emit a global PROGMEM array once per distinct type and contents; later calls reuse it. + + The array is ``static constexpr``, so elements must be constant expressions and lambdas + must be captureless. Its name is made unique against every config id and variable. + ``share=False`` always emits a new array, e.g. for lambdas that may keep static state. + """ + from esphome.config import iter_ids + from esphome.config_validation import RESERVED_IDS + + arrays: dict[str, MockObj] = CORE.data.setdefault("shared_progmem_array", {}) + rhs = safe_exp(rhs) + key = f"{type_} {rhs}" + if share and (array := arrays.get(key)) is not None: + return array + used = {str(i) for i, _ in iter_ids(CORE.config)} + used |= {str(i) for i in CORE.variables} + used |= set(RESERVED_IDS) | CORE.loaded_integrations + id_ = ID(ensure_unique_string(name, used), is_declaration=True, type=type_) + # Global, so any scope can use it; anything a lambda references is already declared. + CORE.add_global(ProgmemAssignmentExpression(type_, id_, rhs)) + array = MockObj(id_, ".") + CORE.register_variable(id_, array) + if share: + arrays[key] = array + return array + + def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") @@ -670,6 +722,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. @@ -1211,8 +1285,14 @@ def call_lambda(lamb: LambdaExpression) -> 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() - if re.match(r"^return\b", expr) and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression + # 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): 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/_tool_resolution.py b/esphome/espidf/_tool_resolution.py new file mode 100644 index 00000000000..624a07fa25b --- /dev/null +++ b/esphome/espidf/_tool_resolution.py @@ -0,0 +1,67 @@ +"""Shared tool resolution for the sibling idf_tools-backed scripts. + +Importable because ``python