diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000000..f0c2891ba4 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,116 @@ +--- +name: code-review +description: Review guidance for ESPHome pull requests. Use this when reviewing a pull request that changes ESPHome Python, C++, or component code, to check it against the project's coding conventions, embedded-systems memory rules, testing requirements, and breaking-change policy. +--- + +# Reviewing ESPHome pull requests + +ESPHome parses YAML into C++ firmware for memory-constrained microcontrollers +(ESP32, ESP8266, RP2040, LibreTiny). Review changes with that in mind: RAM and +flash are scarce, and code runs unattended for months. + +`AGENTS.md` in the repository root is the full contributor guide and the +authority when it disagrees with this summary. The developer documentation at +https://developers.esphome.io explains the component lifecycle and the reasoning +behind these rules. This skill lists the concrete things worth flagging in a +review; read `AGENTS.md` for the detail behind any item. + +Only raise findings that the diff actually introduces or changes. Do not ask for +drive-by cleanup of pre-existing code the PR did not touch. + +## Memory and embedded constraints (highest value) + +Heap allocation after `setup()` is treated as a reliability bug, not a +performance nit, because it fragments a small shared heap. Flag: + +- New heap allocation on a hot path or after setup that could be avoided. +- `std::vector` where the size is known at compile time (use `std::array`, or + `StaticVector` when a `push_back` API is needed) or fixed at runtime + init (use `FixedVector`). +- Listener / child-entity registration lists stored as `std::vector`; these have + a compile-time-known count and should use `cg.slot_counter()` plus + `StaticVector`. +- `std::vector` for a byte buffer that never grows: prefer + `std::unique_ptr` or `std::array`. +- `std::map` / `std::set` / `std::unordered_map` for small datasets (1-16 + elements): a `std::vector` of a small struct with linear search is lighter. +- `std::deque` anywhere: it allocates 512-byte blocks and should be avoided. +- `std::string` storing a value set once from config: prefer `StringRef` (the + literal already lives in flash). +- `std::string` / `std::to_string` / string-returning helpers on hot paths where + a buffer or view API exists. + +## C++ conventions + +- Include what you use: a file referencing a symbol must include the header that + declares it, even if it currently arrives transitively. New or changed uses of + a symbol need the matching include. +- Prefix all member access with `this->`. +- Naming: `lower_snake_case` for functions/methods/variables, `UpperCamelCase` + for classes/structs/enums, `UPPER_SNAKE_CASE` for namespace-scope constants, + trailing underscore on protected/private fields. +- `enum class` values must be prefixed with the enum name in `UPPER_SNAKE_CASE` + (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Bare names like `SUCCESS`, + `FAIL`, or `OK` collide with SDK macros on some platforms and break the build. +- Prefer `const`/`enum` over `#define`; `#define` is only for conditional + compilation and code-generation sizes. +- Never call `millis()` in a `loop()` body; use + `App.get_loop_component_start_time()`. A rate-limit gate below ~16 ms (the loop + period) does nothing. +- Pick the timing primitive by cadence: gated `loop()` under 250 ms, + `set_interval` at 500 ms and above. +- Do not override a base method to return the value it already returns (e.g. + `get_setup_priority()` returning `setup_priority::DATA`). +- Wrap string literals passed as printf `%s` args in `LOG_STR_LITERAL()`. +- Required, invariant dependencies should be constructor parameters, not setters. +- Callback registration methods must be templated (`template`), not + typed as `std::function`, so lightweight forwarders avoid a heap allocation. +- Two-space indent, `using` over `typedef`, wrap at 120 columns. + +## Python conventions + +- Type-annotate every new function signature (params and return), new dataclass + fields, and new module-level variables. Import `ConfigType` from + `esphome.types`. +- Use the walrus operator to avoid a double lookup, e.g. + `if (blah := config.get(CONF_BLAH)) is not None:`. +- Reuse existing validators from `config_validation.py` (`cv.rename_key`, + `cv.has_exactly_one_key`, etc.) via `cv.All(...)` instead of hand-rolling. +- `esphome/const.py` is frozen: no new `CONF_` constants there. Define them in + the component's own `.py`, or in `esphome/components/const/__init__.py` when + shared. The same constant defined in three or more component files fails CI. +- State that must persist during code generation goes in `CORE.data` namespaced + under the component `DOMAIN` (a `@dataclass`), not module-level mutable globals. +- Prefer callback-based triggers via `build_callback_automation()`; only use a + `Trigger` subclass when the forwarder needs mutable state. + +## Testing and coverage + +- New and changed lines and branches need test coverage, including defensive + early-returns, error paths, and no-op guards. A mocked-out function is not + covered; exercise the real call path too. +- Component YAML tests live in `tests/components//`. Never define + buses (uart, i2c, spi, modbus) directly in a test file: pull them from + `tests/test_build_components/common/` through dict-style `packages:` so CI can + group builds. List-style packages or top-level merge keys block grouping. +- Config-only checks use the `validate.*.yaml` prefix; compiled checks use + `test.*.yaml`. + +## Breaking changes and public API + +- Base classes under `esphome/core/` and documented config options are public + API. Undocumented `public` members of a component are internal. +- A breaking change needs justification, a migration path in the PR description, + and a deprecation window where feasible (`ESPDEPRECATED` in C++, + `cv.rename_key(..., removed_in=...)` in Python). Changing a codegen-injected + lambda signature is not a breaking change. + +## Process and PR hygiene + +- PR titles start with a `[tag]` prefix: the component name (e.g. `[uart] ...`) + or `[core]` for shared code. +- Prose in docs, comments, and commit messages should be plain English. Keep + inline comments short and only where the code is not self-explanatory; do not + restate what the code says. +- Verify the PR fills out `.github/PULL_REQUEST_TEMPLATE.md` and adds + `CODEOWNERS` entries for a new component. diff --git a/.claude/skills/pr-workflow/SKILL.md b/.agents/skills/pr-workflow/SKILL.md similarity index 100% rename from .claude/skills/pr-workflow/SKILL.md rename to .agents/skills/pr-workflow/SKILL.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6f7e892284..8527cf491b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -31,7 +31,4 @@ RUN \ platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 -COPY script/platformio_install_deps.py platformio.ini ./ -RUN ./platformio_install_deps.py platformio.ini --libraries --platforms --tools - WORKDIR /workspaces diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 44722ec85c..2244963a79 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,29 @@ body: If you have a feature request or enhancement, please [request them here instead][fr]. [fr]: https://github.com/orgs/esphome/discussions + - type: markdown + attributes: + value: | + ## Use of AI in bug reports + + AI tools are good at carrying out well-defined tasks, but they are not good at troubleshooting. + Please do NOT paste an AI-generated wall of text into the issue template - if the AI hasn't solved + your problem, its wild guesses are not likely to help. + + Please DO include your own words and observations, compile/boot logs, and + especially a minimal reproducible example of your YAML configuration that demonstrates the problem. + + It is however quite acceptable to use AI to translate your *own* report, + if you aren't a competent English speaker. + + If you really think it will be useful to include an AI's analysis, preferably wrap it in a `
` block which will be collapsed by default. + + If you are using AI to help solve a problem, rather than asking it to speculate about what the problem is, + it can be more useful to ask it to create a step-by-step troubleshooting procedure. + AI is also useful for generating boilerplate code, such as a minimal reproducible example of your YAML + configuration that demonstrates the problem. + + Used properly, AI can be a useful tool to help you solve your problem, but don't let it get in the way. - type: textarea validations: required: true diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml index 133d7ca8d8..f6565a3916 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 0000000000..c9e194d667 --- /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 ce14b0152a..affe8fc2d6 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/skills b/.github/skills new file mode 120000 index 0000000000..2b7a412b8f --- /dev/null +++ b/.github/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 63219a1dbc..d0bd1de9ef 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 829bdd5f98..5bcdb22cb8 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 d7c93b3b86..5eb96af59b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -102,6 +102,8 @@ jobs: device-builder: ${{ steps.determine.outputs.device-builder }} esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} + esp8266-native: ${{ steps.determine.outputs.esp8266-native }} + esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -165,6 +167,8 @@ jobs: echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT + echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT + echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -183,6 +187,33 @@ jobs: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + seed-esp8266-native-cache: + name: Seed the esp8266 native toolchain cache + runs-on: ubuntu-24.04 + needs: + - common + # PR-branch cache saves are invisible to other PRs, so dev pushes seed + # the shared entry the component matrix, the memory impact jobs and + # test-esp8266-native restore. Only dev: the composite action saves + # nowhere else, so a beta/release push would download the toolchain and + # discard it. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + timeout-minutes: 15 + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache the native toolchain + uses: ./.github/actions/cache-arduino8266 + - name: Install the native toolchain + run: | + . venv/bin/activate + python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)" + ci-custom: name: Run script/ci-custom runs-on: ubuntu-24.04 @@ -244,11 +275,20 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Read prek version from requirements_test.txt + id: prek + # requirements_test.txt is the only place the version is pinned, so a + # Dependabot bump there is picked up here without a second edit. + run: | + if ! version=$(sed -nE 's/^prek==([^[:space:]#]+).*/\1/p' requirements_test.txt) || [ -z "$version" ]; then + echo "::error::No prek== pin found in requirements_test.txt." + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" - name: Run prek uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 with: - # Keep in sync with requirements_test.txt. - prek-version: "0.4.11" + prek-version: ${{ steps.prek.outputs.version }} # This job only runs on pull requests, so nothing ever populates # the cache on dev. Every run would miss and then write a per-pull # request copy, which is what the old seed-cache job existed to @@ -259,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 @@ -312,7 +352,7 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -341,7 +381,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Report empty upload to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: run_command: empty-upload force: true @@ -360,14 +400,9 @@ jobs: matrix: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} env: - # What the cache steps persist; libdeps is excluded (keyed per xdist - # worker and env, it never crosses runs). - INTEGRATION_PIO_CACHE_PATH: | - ~/.esphome-integration-tests/platformio/platforms - ~/.esphome-integration-tests/platformio/packages - ~/.esphome-integration-tests/platformio/appstate.json - ~/.esphome-integration-tests/platformio/.cache - ~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json + # Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds + # download, shared per xdist worker by tests/integration/conftest.py + INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -387,14 +422,13 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - - name: Restore integration PlatformIO cache - # Native platform + toolchain installed by shared_platformio_cache in - # tests/integration/conftest.py; a miss self-heals, so no restore-keys. - id: pio-cache + - name: Restore integration library cache + # A miss or a changed pin self-heals with a download, so no restore-keys + id: library-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }} - name: Restore Python virtual environment id: cache-venv uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -404,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -462,16 +496,16 @@ jobs: # A full cron period of margin for the weekly refresh retention-days: 14 - name: Print ccache statistics - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). - run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save integration PlatformIO cache + # esphome stores the host build's ccache under the machine-global + # cache dir (see get_build_env() in esphome/host/toolchain.py). + run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s + - name: Save integration library cache # Bucket 0 only; the others would race the same immutable key. - if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true' + if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: ${{ steps.pio-cache.outputs.cache-primary-key }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: ${{ steps.library-cache.outputs.cache-primary-key }} import-time: name: Check import esphome.__main__ time @@ -592,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 @@ -1088,6 +1122,11 @@ jobs: uses: ./.github/actions/cache-sdk-nrf with: restore-only: true + - name: Cache the native ESP8266 toolchain + # Only batches whose test platforms include esp8266; never saves + # here, it reuses the install the dev seed job cached. + if: matrix.batch.needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -1186,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 @@ -1228,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..." @@ -1237,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 @@ -1265,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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1406,12 +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' @@ -1496,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: | @@ -1599,6 +1686,7 @@ jobs: needs: - common - seed-apt-cache + - seed-esp8266-native-cache - determine-jobs - ci-custom - pylint @@ -1614,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 aab3dea592..da02a5dd09 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 104988d7a5..bfe96f3944 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 e09e9bf2d1..74ac5fe05a 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 d0dee8165c..efccad9ad9 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 aa31094f81..43b7e6663d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome # GitHub App token so the labels, comments and closures come from # esphome[bot] instead of github-actions[bot]. - uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main + uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: @@ -33,7 +33,7 @@ jobs: and will be closed if no further activity occurs within 7 days. If you are the author of this PR, please leave a comment if you want - to keep it open. Also, please rebase your PR onto the latest dev + to keep it open. Also, please merge the latest dev branch into your branch to ensure that it's up to date with the latest changes. Thank you for your contribution! diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 72987c25b1..5f0966c151 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-dependency-versions.yml b/.github/workflows/sync-dependency-versions.yml new file mode 100644 index 0000000000..5599691ed1 --- /dev/null +++ b/.github/workflows/sync-dependency-versions.yml @@ -0,0 +1,94 @@ +# Keeps pre-commit hook revs in sync with the requirements files. +# +# Dependabot only bumps the pins in requirements*.txt. Some of those tools +# are pinned again as hook revs in .pre-commit-config.yaml. This workflow +# runs script/sync_dependency_versions.py against the pull request branch +# and pushes a commit with the revs updated. + +name: Sync dependency versions + +on: + # pull_request_target rather than pull_request so the App secret is + # available on Dependabot pull requests (pull_request runs opened by + # Dependabot only see Dependabot secrets). The job below only touches + # branches in this repository and only ever executes the script from the + # base branch checkout, so fork code never runs with the token. + pull_request_target: + types: [opened, synchronize, reopened] + paths: + - requirements_dev.txt + - requirements_test.txt + - .pre-commit-config.yaml + - script/sync_dependency_versions.py + +# The push to the pull request branch uses the App token minted below, so +# the workflow's GITHUB_TOKEN does not need any scopes. +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sync: + name: Sync pinned versions + runs-on: ubuntu-latest + # Same-repository branches only: a push to a fork is not possible with + # this token, and it keeps untrusted heads out of a privileged job. + if: >- + github.repository == 'esphome/esphome' + && github.event.pull_request.head.repo.full_name == github.repository + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + # A push made with the workflow's own GITHUB_TOKEN would not start + # CI on the new commit; a push with the App token does. + permission-contents: write # git push of the sync commit to the pull request branch + + - name: Check out base branch + # Provides the script that runs below. Deliberately the base branch + # so the pull request cannot change what executes here. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Check out pull request branch + # No allow-unsafe-pr-checkout here on purpose: checkout v7 only + # refuses heads that live in a different repository, and the job + # condition above already limits runs to same-repository branches. + # Leaving it off keeps that refusal as a backstop for fork heads. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + path: pull-request + token: ${{ steps.generate-token.outputs.token }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install yamlrocks + # The script edits YAML through yamlrocks. Take the pin from the + # base branch requirements so this workflow has no copy of its own. + run: pip install "$(grep -E '^yamlrocks==' requirements_test.txt | cut -d'#' -f1)" + + - name: Sync pinned versions + run: python script/sync_dependency_versions.py --root pull-request + + - name: Push changes + working-directory: pull-request + run: | + if git diff --quiet; then + echo "All pinned versions already match the requirements files." + exit 0 + fi + git config user.name "esphome[bot]" + git config user.email "115708604+esphome[bot]@users.noreply.github.com" + git commit -am "Sync pinned tool versions with requirements files" + git push diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 9100064176..a0725374f9 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ea799aa4d..727e8e2f9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,6 @@ --- # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks - ci: autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit @@ -11,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.3 + rev: v0.16.10 hooks: # Run the linter. - id: ruff @@ -19,7 +18,7 @@ repos: # Run the formatter. - id: ruff-format - repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 + rev: 7.4.1 hooks: - id: flake8 additional_dependencies: @@ -42,7 +41,7 @@ repos: - id: pyupgrade args: [--py312-plus] - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint exclude: ^(\.clang-format|\.clang-tidy)$ diff --git a/AGENTS.md b/AGENTS.md index e932c50f32..6a60fc47b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,6 +322,25 @@ file does, and it is the authority when they disagree. The most useful starting var = await switch.new_switch(config) ``` + - **Optional child entities of a hub:** bind the config once with `sensor.sub_sensors(config)` (or + `sub_binary_sensors`, `sub_text_sensors`, `sub_buttons`, `sub_switches`, `sub_numbers`, + `sub_selects` in their domains), adding `parent=hub` for entities that derive from `Parented`, + then make one call per key, even when there is only one. A call creates the entity only when its key + is configured, passes it to the setter and returns it (or `None`); extra arguments such as + `min_value` or `options` go on the call. Always name the setter explicitly on the object that owns + it, never with `getattr` and an f-string, and keep that variable short (`var` for the component + itself, `hub` for one fetched with `cg.get_variable`) so the calls fit on one line. Loops whose + setter also takes an index, such as `set_gate_threshold(x, n)`, stay as they are. + ```python + async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + buttons = button.sub_buttons(config, parent=var) + await buttons(CONF_RESTART, var.set_restart_button) + ``` + * **Automations (Triggers, Actions, Conditions):** Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true). @@ -431,7 +450,31 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + Register it without writing a builder: + ```python + automation.register_simple_action( + "my_component.do_something", MyAction, schema, synchronous=True + ) + ``` + The constructor receives the object named by `config[CONF_ID]`. Use `register_bare_action` for a + no-argument constructor, `register_parented_action` for a class deriving from `Parented`, and + the `@automation.register_action(...)` decorator only when the builder must also set fields. + + Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use. + + **Actions that only forward templatable values to their parent need no C++ class.** Register them + with `register_apply_action`; do not write a `TEMPLATABLE_VALUE` class or a builder for this shape. + ```python + automation.register_apply_action( + "my_component.set_gains", + schema, + automation.ApplyField(CONF_KP, "set_kp", cg.float_), + automation.ApplyField(CONF_KI, "set_ki", cg.float_), + ) + ``` + The `ApplyField`, `ApplyCall` and `register_apply_action` docstrings in `esphome/automation.py` cover + the rest; `cover.control` and `cover.template.publish` are in-tree examples. `TEMPLATABLE_VALUE` with + `cg.templatable` stays for actions whose `play()` has real logic beyond forwarding values. * **Conditions:** ```cpp @@ -443,7 +486,21 @@ file does, and it is the authority when they disagree. The most useful starting MyComponent *parent_; }; ``` - Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`. + Register with `automation.register_simple_condition("my_component.is_active", MyCondition, schema)`; + `register_bare_condition`, `register_parented_condition` and the decorator follow the action rules. + + **Conditions that only test their parent need no C++ class either.** Register them with + `register_apply_condition`; the expression is applied to the parent, and an `ApplyCall` compares + against config values. + ```python + automation.register_apply_condition("my_component.is_active", schema, "is_active()") + automation.register_apply_condition( + "my_component.state_is", + schema, + automation.ApplyCall("state == {}", ((CONF_STATE, cg.bool_),)), + ) + ``` + `cover.is_open`, `rtttl.is_playing` and `component.is_idle` are in-tree examples. * **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`. @@ -553,6 +610,7 @@ file does, and it is the authority when they disagree. The most useful starting 4. **Lint:** Run `prek` to ensure code is compliant. 5. **Commit:** Commit your changes. There is no strict format for commit messages. 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. + 7. **Comments:** When commenting on GitHub PRs or issues, don't tag contributors, especially bots. Avoid referring to list items (e.g. from reviews) with the form #nn - this will be interpreted by GitHub as a reference to issue or PR nn. Keep comments short and exclude irrelevant details, backstories, restatement of previous comments and anything that is already obvious to the reader. * **Documentation Contributions:** * Documentation is hosted in the separate `esphome/esphome.io` repository. @@ -628,6 +686,9 @@ file does, and it is the authority when they disagree. The most useful starting _request_listener_slot() cg.add(hub.register_listener(var)) ``` + When several instances each own a list declared at the same size (one per hub of a + `MULTI_CONF` component), pass the owning object as the key, `_request_listener_slot(str(hub))`; + the define is then the largest count any one key requested instead of the total. ```cpp #ifdef MY_COMPONENT_LISTENER_COUNT void register_listener(MyComponentListener *listener); @@ -695,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) @@ -839,7 +902,7 @@ file does, and it is the authority when they disagree. The most useful starting cv.rename_key( CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component" ), - cv.Schema({ ... }), + cv.Schema({...}), ) ``` For other deprecations, warn manually during validation: diff --git a/CODEOWNERS b/CODEOWNERS index f91bc00ae5..b5138c0612 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -100,6 +100,7 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core esphome/components/bytebuffer/* @clydebarrow @@ -111,6 +112,8 @@ esphome/components/captive_portal/* @esphome/core esphome/components/cc1101/* @gabest11 @lygris esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke +esphome/components/cdc_acm_uart/* @kbx81 +esphome/components/cdc_acm_uart/bridge/* @kbx81 esphome/components/ch422g/* @clydebarrow @jesterret esphome/components/ch423/* @dwmw2 esphome/components/chsc6x/* @kkosik20 @@ -122,6 +125,7 @@ esphome/components/combination/* @Cat-Ion @kahrendt esphome/components/const/* @esphome/core esphome/components/coolix/* @glmnet esphome/components/copy/* @OttoWinter +esphome/components/counter/* @clydebarrow esphome/components/cover/* @esphome/core esphome/components/cs5460a/* @balrog-kun esphome/components/cse7761/* @berfenger @@ -179,15 +183,16 @@ esphome/components/esp32_camera_web_server/* @ayufan esphome/components/esp32_can/* @Sympatron esphome/components/esp32_hosted/* @swoboda1337 esphome/components/esp32_hosted/update/* @swoboda1337 -esphome/components/esp32_improv/* @jesserockz esphome/components/esp32_rmt/* @jesserockz esphome/components/esp32_rmt_led_strip/* @jesserockz esphome/components/esp8266/* @esphome/core 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 @@ -242,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 @@ -263,8 +271,10 @@ esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt esphome/components/iaqcore/* @yozik04 +esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 esphome/components/improv_base/* @esphome/core +esphome/components/improv_ble/* @jesserockz esphome/components/improv_serial/* @esphome/core esphome/components/ina226/* @latonita @Sergio303 esphome/components/ina260/* @mreditor97 @@ -277,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 @@ -418,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 @@ -425,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 @@ -453,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 @@ -474,6 +488,7 @@ esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt +esphome/components/sendspin/switch/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core @@ -532,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/stts22h/* @B48D81EFCC esphome/components/substitutions/* @esphome/core esphome/components/sun/* @OttoWinter @@ -542,11 +558,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 @@ -555,6 +575,7 @@ esphome/components/template/datetime/* @rfdarter esphome/components/template/event/* @nohat esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse +esphome/components/tfluna/* @candrews esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core @@ -586,11 +607,14 @@ esphome/components/uart/* @esphome/core esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd esphome/components/uart/packet_transport/* @clydebarrow +esphome/components/uart_mux/* @kbx81 +esphome/components/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 @@ -630,9 +654,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/Doxyfile b/Doxyfile index 65d975b61e..ec43afe83a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.9.1 +PROJECT_NUMBER = 2026.10.0b1 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/docker/Dockerfile b/docker/Dockerfile index e00570c8ff..9f29062e09 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 c88a78f97e..65d8fd61b5 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 bb36cfcdb4..5b0ff67909 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 20fada5f13..dd7f1bddc2 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 138aa9e282..f05e400c53 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 0000000000..99eab33ecb --- /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 80b52260e4..5a80fd5b1f 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 30e97f55eb..6c984050f4 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 4fb3689818..8d7107b782 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 19041ac807..7d8195a17b 100644 --- a/esphome/analyze_memory/toolchain.py +++ b/esphome/analyze_memory/toolchain.py @@ -37,7 +37,7 @@ def find_elf_path(build_path: Path) -> Path | None: """ name = build_path.name for candidate in ( - # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies + # Native ESP-IDF: the build writes build/.elf, which ESPHome copies # to build/firmware.elf (see espidf.toolchain.create_elf_copy) build_path / "build" / "firmware.elf", # PlatformIO @@ -68,12 +68,16 @@ def idedata_candidates(build_path: Path) -> list[Path]: The candidate idedata JSON paths, most specific first """ name = build_path.name + data_dir = build_path.parent.parent / "idedata" + # Native backends suffix the cache by toolchain (.arduino.json) + suffixed = sorted(data_dir.glob(f"{name}.*.json")) if data_dir.is_dir() else [] return [ # In .pioenvs for test builds build_path / ".pioenvs" / name / "idedata.json", # Both toolchains cache it in the data dir, which holds this build dir: # /idedata/.json next to /build/ build_path.parent.parent / "idedata" / f"{name}.json", + *suffixed, # Regular builds, invoked from the config dir or from anywhere Path.cwd() / ".esphome" / "idedata" / f"{name}.json", Path.home() / ".esphome" / "idedata" / f"{name}.json", diff --git a/esphome/api_client.py b/esphome/api_client.py index fb41075de8..2b93b4790f 100644 --- a/esphome/api_client.py +++ b/esphome/api_client.py @@ -23,9 +23,7 @@ from esphome.util import safe_print if TYPE_CHECKING: from collections.abc import Callable - from aioesphomeapi.api_pb2 import ( - SubscribeLogsResponse, # pylint: disable=no-name-in-module - ) + from aioesphomeapi.api_pb2 import SubscribeLogsResponse # pylint: disable=no-name-in-module _LOGGER = logging.getLogger(__name__) diff --git a/esphome/arduino/library.py b/esphome/arduino/library.py index e224e62589..143b6d23a0 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 663002b3b1..b731b74675 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 0000000000..5bc1889a21 --- /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 1689d29c42..b69edd04a1 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 @@ -102,6 +134,101 @@ def register_condition(name: str, condition_type: MockObjClass, schema: cv.Schem return CONDITION_REGISTRY.register(name, condition_type, schema) +async def _build_with_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + parent = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(automation_id, template_arg, parent) + + +async def _build_without_parent( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + return cg.new_Pvariable(automation_id, template_arg) + + +async def _build_parented( + config: ConfigType, + automation_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + var = cg.new_Pvariable(automation_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +def register_simple_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes the object named by ``config[CONF_ID]``. + + Use the ``register_action`` decorator instead when the builder must also set fields. + """ + register_action(name, action_type, schema, synchronous=synchronous)( + _build_with_parent + ) + + +def register_simple_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_simple_action``.""" + register_condition(name, condition_type, schema)(_build_with_parent) + + +def register_bare_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action whose constructor takes no arguments.""" + register_action(name, action_type, schema, synchronous=synchronous)( + _build_without_parent + ) + + +def register_bare_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_bare_action``.""" + register_condition(name, condition_type, schema)(_build_without_parent) + + +def register_parented_action( + name: str, + action_type: MockObjClass, + schema: cv.Schema, + *, + synchronous: bool, +) -> None: + """Register an action deriving from ``Parented``. + + The object is constructed without arguments and ``set_parent()`` receives the object + named by ``config[CONF_ID]``. + """ + register_action(name, action_type, schema, synchronous=synchronous)(_build_parented) + + +def register_parented_condition( + name: str, condition_type: MockObjClass, schema: cv.Schema +) -> None: + """Condition counterpart of ``register_parented_action``.""" + register_condition(name, condition_type, schema)(_build_parented) + + Action = cg.esphome_ns.class_("Action") Trigger = cg.esphome_ns.class_("Trigger") ACTION_REGISTRY = Registry() @@ -112,6 +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): @@ -359,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( @@ -534,44 +950,20 @@ async def lambda_action_to_code( return new_lambda_pvariable(action_id, lambda_, StatelessLambdaAction, template_arg) -@register_action( +register_simple_action( "component.update", UpdateComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_update_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) -@register_action( +register_simple_action( "component.suspend", SuspendComponentAction, - maybe_simple_id( - { - cv.Required(CONF_ID): cv.use_id(cg.PollingComponent), - } - ), + maybe_simple_id({cv.Required(CONF_ID): cv.use_id(cg.PollingComponent)}), synchronous=True, ) -async def component_suspend_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - comp = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, comp) @register_action( diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py new file mode 100644 index 0000000000..5b14af5a1d --- /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 2ef89cf595..e7a2dc1536 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: @@ -90,9 +249,10 @@ def get_project_cmakelists( """ idf_target = variant_to_idf_target(get_esp32_variant()) - # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and - # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get - # --format=raw because the legacy mode doesn't support it. + # esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng; + # 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks + # total_size, hence the ELF fallback in espidf/size_summary.py; both + # go away together when 1.x support is dropped. size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else "" # Project-wide compile options: -D defines and -W warning flags (skip @@ -122,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 @@ -199,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} @@ -211,12 +390,22 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) project({CORE.name}) -# Emit raw JSON size data for ESPHome to read post-build. +{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=raw + 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 ) @@ -279,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 0000000000..f1e53e1ccb --- /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 0a12d344a0..b68ee90f12 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 5b5c7f247f..df9aa8c9a0 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 038fe64970..62bea83022 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 0000000000..1d2dc1b3ca --- /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 8c25bc9513..7634b0f2f3 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 0000000000..8e72b36b3f --- /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 0000000000..c2e04c9c65 --- /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 0000000000..52cd6c937a --- /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 e7193a8e2a..85036c166f 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 0000000000..fa6e1fa266 --- /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 0000000000..cbef68fb70 --- /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 5debb52b4e..e7c08048b9 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/component_aliases.py b/esphome/component_aliases.py index e701bd98d4..53a34d1e15 100644 --- a/esphome/component_aliases.py +++ b/esphome/component_aliases.py @@ -6,5 +6,6 @@ See the component-alias section of esphome/loader.py. # alias -> (canonical component, removal version or None) COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = { + "esp32_improv": ("improv_ble", "2027.4.0"), "rp2040": ("rp2", "2027.7.0"), } diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 7131898747..46b7e7a2ff 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -94,7 +94,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v /// - SamplingMode::MIN: Use the lowest sample value /// - SamplingMode::MAX: Use the highest sample value /// @param sampling_mode The desired sampling mode to use for aggregating ADC samples. - void set_sampling_mode(SamplingMode sampling_mode); + void set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } /// Perform a single ADC sampling operation and return the measured value. /// This function handles raw readings, calibration, and averaging as needed. diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 5ca58df10e..70211000c3 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -76,6 +76,4 @@ void ADCSensor::set_sample_count(uint8_t sample_count) { } } -void ADCSensor::set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; } - } // namespace esphome::adc diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp index 230548ae94..377677ccdb 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 8ebc8da544..8b5f51f971 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 8606e7c247..d20d80b2fc 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 ae669d0000..add86e16a6 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 50e2f81f1b..b4942fe390 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 f0f8856614..0000000000 --- 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 58fde11a3d..2cec58934d 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 8ea79e644f..75c1eca594 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 2f5d4c7c2b..78206ff3f8 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 e748b8621b..1215044ca1 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.h +++ b/esphome/components/alarm_control_panel/alarm_control_panel.h @@ -130,6 +130,12 @@ class AlarmControlPanel : public EntityBase { // is the state one of the armed states bool is_state_armed(AlarmControlPanelState state); + /// Armed, pending (entry delay) or triggered; not ARMING (exit delay). Backs the is_armed and ready + /// conditions. + bool is_armed_pending_or_triggered() { + auto state = this->get_state(); + return this->is_state_armed(state) || state == ACP_STATE_PENDING || state == ACP_STATE_TRIGGERED; + } protected: friend AlarmControlPanelCall; @@ -138,11 +144,11 @@ class AlarmControlPanel : public EntityBase { // in order to store last panel state in flash ESPPreferenceObject pref_; // current state - AlarmControlPanelState current_state_; + AlarmControlPanelState current_state_{ACP_STATE_DISARMED}; // the desired (or previous) state - AlarmControlPanelState desired_state_; + AlarmControlPanelState desired_state_{ACP_STATE_DISARMED}; // last time the state was updated - uint32_t last_update_; + uint32_t last_update_{0}; // the call control function virtual void control(const AlarmControlPanelCall &call) = 0; // state callback - passes the new state to listeners diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index dcb5121c60..86bdac37ec 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 2c1a04ef27..d8d59242fa 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 febb11409c..39ded956c3 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 ffac0e6407..30a1b1d8a3 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 80341972a9..ef746f3e52 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 64cddbf09c..4ee060c121 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 0265a350f7..09e0e8753c 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 272b078690..5e68e20031 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,8 +143,16 @@ 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 +# skips the setter when the config equals them. +DEFAULT_PORT = 6053 +DEFAULT_REBOOT_TIMEOUT = "15min" +DEFAULT_BATCH_DELAY = "100ms" +DEFAULT_LISTEN_BACKLOG = 4 + def _register_provisioning_source(config: ConfigType) -> ConfigType: """Register the API as a provisioning source when encryption is enabled. @@ -285,14 +302,77 @@ 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( { cv.GenerateID(): cv.declare_id(APIServer), - cv.Optional(CONF_PORT, default=6053): cv.port, + cv.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, # Removed in 2026.1.0 - kept to provide helpful error message cv.Optional(CONF_PASSWORD): cv.invalid( "The 'password' option has been removed in ESPHome 2026.1.0.\n" @@ -305,14 +385,16 @@ CONFIG_SCHEMA = cv.All( "Or visit https://esphome.io/components/api/#configuration-variables" ), cv.Optional( - CONF_REBOOT_TIMEOUT, default="15min" + CONF_REBOOT_TIMEOUT, default=DEFAULT_REBOOT_TIMEOUT ): cv.positive_time_period_milliseconds, cv.Exclusive( CONF_SERVICES, group_of_exclusion=CONF_ACTIONS ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, - cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All( + cv.Optional(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)), ), @@ -367,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, ) @@ -423,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( @@ -456,17 +566,24 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # Track controller registration for StaticVector sizing - CORE.register_controller() + CORE.register_controller(var) # Request a log listener slot for API log streaming request_log_listener() - cg.add(var.set_port(config[CONF_PORT])) - cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) - cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) - if CONF_LISTEN_BACKLOG in config: - cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) + # Skip the setters when the config matches the C++ initializers (DEFAULT_*). + if (port := config[CONF_PORT]) != DEFAULT_PORT: + cg.add(var.set_port(port)) + if (reboot_timeout := config[CONF_REBOOT_TIMEOUT]) != cv.time_period( + DEFAULT_REBOOT_TIMEOUT + ): + cg.add(var.set_reboot_timeout(reboot_timeout)) + if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY): + cg.add(var.set_batch_delay(batch_delay)) + if ( + 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]) @@ -571,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( @@ -589,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 @@ -602,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) @@ -645,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: @@ -679,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), @@ -694,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. @@ -715,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") @@ -796,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, } ) @@ -817,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( @@ -872,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" @@ -991,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 3a0e0abea9..2648b5d0d8 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,7 +78,9 @@ 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) {} } @@ -112,6 +116,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. @@ -227,6 +236,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 { @@ -331,6 +341,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 ==================== @@ -379,6 +393,11 @@ message ZWaveProxyCapabilities { uint32 home_id = 2; } +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; @@ -388,6 +407,90 @@ 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"]; + WizardCapabilities wizard = 5 [(field_ifdef) = "USE_API_WIZARD"]; +} + +// ==================== 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 { @@ -802,6 +905,9 @@ message SwitchStateResponse { fixed32 key = 1 [(force) = true]; bool state = 2; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; + // If the switch does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 4; } message SwitchCommandRequest { option (id) = 33; @@ -1243,6 +1349,9 @@ message ClimateStateResponse { float current_humidity = 14; float target_humidity = 15; uint32 device_id = 16 [(field_ifdef) = "USE_DEVICES"]; + // If the climate device does not have a valid state yet. + // Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller + bool missing_state = 17; } message ClimateCommandRequest { option (id) = 48; @@ -1329,6 +1438,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 @@ -1429,7 +1541,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"]; @@ -2673,7 +2785,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 @@ -2687,7 +2799,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; @@ -2696,6 +2808,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 @@ -2726,7 +2860,8 @@ enum SerialProxyParity { SERIAL_PROXY_PARITY_ODD = 2; } -// Configure UART parameters for a serial proxy instance +// Configure UART parameters for a serial proxy instance. Only the subscribed client may +// configure the port; others are refused with PORT_IN_USE (since API 1.17). message SerialProxyConfigureRequest { option (id) = 138; option (source) = SOURCE_CLIENT; @@ -2752,7 +2887,8 @@ message SerialProxyDataReceived { bytes data = 2; // Raw data received from the serial device } -// Write data to a serial device +// Write data to a serial device. Only the subscribed client may write; writes from +// others are ignored (since API 1.17). message SerialProxyWriteRequest { option (id) = 140; option (source) = SOURCE_CLIENT; @@ -2763,7 +2899,8 @@ message SerialProxyWriteRequest { bytes data = 2; // Raw data to write to the serial device } -// Set modem control pin states (RTS and DTR) +// Set modem control pin states (RTS and DTR). Only the subscribed client may set them; +// others are refused with PORT_IN_USE (since API 1.17). message SerialProxySetModemPinsRequest { option (id) = 141; option (source) = SOURCE_CLIENT; @@ -2802,6 +2939,7 @@ enum SerialProxyRequestType { // error the device answers with INVALID_ARGUMENT. SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17) } enum SerialProxyStatus { @@ -2814,7 +2952,8 @@ enum SerialProxyStatus { SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value } -// Generic request message for simple serial proxy operations +// Generic request message for simple serial proxy operations. FLUSH requires an active +// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17). message SerialProxyRequest { option (id) = 144; option (source) = SOURCE_CLIENT; @@ -2838,6 +2977,79 @@ message SerialProxyRequestResponse { string error_message = 4; // Additional detail on failure (optional) } +// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL +// activates the port's protocol-aware tap (if one is configured), letting it observe +// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap +// speaks is a property of the device configuration, discoverable from the tap +// component's own API surface. A client that is about to flash firmware selects RAW +// first, which definitively disables that injection. +enum SerialProxyMode { + SERIAL_PROXY_MODE_RAW = 0; + SERIAL_PROXY_MODE_PROTOCOL = 1; +} + +// Only the subscribed client may change the mode; any other caller -- including one that +// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED +// when the port has no protocol-aware tap configured. +message SerialProxySetModeRequest { + option (id) = 152; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyMode mode = 2; +} + +// 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; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cc0543a690..7d56b1ce58 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -198,6 +198,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 } @@ -600,7 +611,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); @@ -709,6 +720,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); } @@ -720,12 +732,7 @@ uint16_t APIConnection::try_send_switch_info(EntityBase *entity, APIConnection * } void APIConnection::on_switch_command_request(const SwitchCommandRequest &msg) { ENTITY_COMMAND_GET(switch_::Switch, a_switch, switch) - - if (msg.state) { - a_switch->turn_on(); - } else { - a_switch->turn_off(); - } + a_switch->control(msg.state); } #endif @@ -759,6 +766,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 | @@ -993,7 +1001,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) { @@ -1452,6 +1462,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(); @@ -1517,8 +1528,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 @@ -1528,6 +1541,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; } @@ -1540,13 +1554,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 @@ -1554,6 +1593,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 @@ -1645,6 +1685,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()) { @@ -1664,6 +1720,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { break; case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE: case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: // Response-only discriminators; never valid in a request ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast(msg.type)); status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT; @@ -1676,6 +1733,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { send_serial_proxy_ack(this, msg.instance, msg.type, status); } +void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) { + auto &proxies = App.get_serial_proxies(); + if (msg.instance >= proxies.size()) { + ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT); + return; + } + serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode); + send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE, + serial_proxy_result_to_status(result)); +} + void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { if (!this->send_message(msg)) { ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); @@ -1802,7 +1872,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 16; + resp.api_version_minor = 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()); @@ -1825,6 +1895,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); } @@ -1947,6 +2030,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; @@ -1999,6 +2085,9 @@ bool APIConnection::send_device_capabilities_response_() { info.port_type = proxy->get_port_type(); info.configured_line_states = proxy->get_configured_modem_pins(); } +#endif +#ifdef USE_API_WIZARD + resp.wizard.configured = true; #endif return this->send_message(resp); } @@ -2213,7 +2302,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); @@ -2257,8 +2352,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) @@ -2651,6 +2750,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 a4c49dccf4..4d927b2885 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -233,9 +233,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 @@ -243,7 +246,11 @@ 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); #endif @@ -272,6 +279,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; @@ -301,6 +314,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); @@ -316,9 +333,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(); @@ -338,18 +361,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. @@ -375,6 +394,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); @@ -387,6 +423,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 @@ -405,16 +444,6 @@ class APIConnection final : public APIServerConnectionBase { void process_state_subscriptions_(); #endif - // Size thunk — converts void* back to concrete type for direct calculate_size() call - template static uint32_t calc_size(const void *msg) { - return static_cast(msg)->calculate_size(); - } - - // Shared no-op encode thunk for empty messages (ESTIMATED_SIZE == 0) - static uint8_t *encode_msg_noop(const void *, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) { - return buf.get_pos(); - } - // Non-template buffer management for send_message bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg); @@ -433,11 +462,7 @@ class APIConnection final : public APIServerConnectionBase { // Hot paths (state/info) go through fill_and_encode_entity_state/info instead. // batch_message_type_ is already set by dispatch_message_ before reaching here. template static uint16_t encode_message_to_buffer(T &msg, APIConnection *conn, uint32_t remaining_size) { - if constexpr (T::ESTIMATED_SIZE == 0) { - return encode_to_buffer_slow(0, &encode_msg_noop, &msg, conn, remaining_size); - } else { - return encode_to_buffer_slow(msg.calculate_size(), &proto_encode_msg, &msg, conn, remaining_size); - } + return encode_to_buffer_slow(T::calc_size_msg(&msg), &T::encode_msg, &msg, conn, remaining_size); } // Non-template core — fills state fields and encodes @@ -449,7 +474,7 @@ class APIConnection final : public APIServerConnectionBase { template static uint16_t fill_and_encode_entity_state(EntityBase *entity, T &msg, APIConnection *conn, uint32_t remaining_size) { - return fill_and_encode_entity_state(entity, msg, &calc_size, &proto_encode_msg, conn, remaining_size); + return fill_and_encode_entity_state(entity, msg, &T::calc_size_msg, &T::encode_msg, conn, remaining_size); } // Non-template core — fills info fields, allocates buffers, and encodes @@ -461,7 +486,7 @@ class APIConnection final : public APIServerConnectionBase { template static uint16_t fill_and_encode_entity_info(EntityBase *entity, T &msg, APIConnection *conn, uint32_t remaining_size) { - return fill_and_encode_entity_info(entity, msg, &calc_size, &proto_encode_msg, conn, remaining_size); + return fill_and_encode_entity_info(entity, msg, &T::calc_size_msg, &T::encode_msg, conn, remaining_size); } // Non-template core — fills device_class, then delegates to fill_and_encode_entity_info @@ -475,8 +500,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 @@ -745,12 +770,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 08520249bf..9b05da76e5 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 a68a0ad0d8..96c302745c 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 400cd1d9b8..21ef085c53 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 366751738e..a807d6e5da 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 0000000000..c86107cef0 --- /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 0000000000..16d50c3425 --- /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 2de1f0a15c..67a5d9792e 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7,1370 +7,1389 @@ 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 -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 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 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); @@ -1381,1114 +1400,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); @@ -2499,1796 +2467,1896 @@ 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; +} +void SerialProxySetModeRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); + break; + } +} +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 +#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 5c3429a63a..9ad114b2ca 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, @@ -356,6 +357,7 @@ enum SerialProxyRequestType : uint32_t { SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2, SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3, SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4, + SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5, }; enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_OK = 0, @@ -366,7 +368,21 @@ enum SerialProxyStatus : uint32_t { SERIAL_PROXY_STATUS_PORT_IN_USE = 5, SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6, }; +enum SerialProxyMode : uint32_t { + SERIAL_PROXY_MODE_RAW = 0, + SERIAL_PROXY_MODE_PROTOCOL = 1, +}; +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 @@ -377,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 @@ -412,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: @@ -438,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 @@ -454,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: @@ -507,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 @@ -522,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 @@ -534,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 @@ -549,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 @@ -608,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 @@ -621,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 @@ -634,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 @@ -648,8 +707,29 @@ 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 + + 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 @@ -660,7 +740,7 @@ class ZWaveProxyCapabilities final : public ProtoMessage { class DeviceCapabilitiesResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 150; - static constexpr uint8_t ESTIMATED_SIZE = 102; + static constexpr uint8_t ESTIMATED_SIZE = 119; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -676,14 +756,65 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { #ifdef USE_SERIAL_PROXY std::array serial_proxies{}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; +#ifdef USE_API_WIZARD + WizardCapabilities wizard{}; +#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 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; @@ -705,10 +836,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 @@ -724,8 +859,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 @@ -744,10 +883,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 @@ -764,8 +907,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 @@ -784,13 +931,15 @@ class CoverCommandRequest final : public CommandProtoMessage { bool has_tilt{false}; float tilt{0.0f}; bool stop{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_FAN @@ -806,8 +955,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 @@ -825,9 +978,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 @@ -851,14 +1008,15 @@ class FanCommandRequest final : public CommandProtoMessage { int32_t speed_level{0}; bool has_preset_mode{false}; StringRef preset_mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_LIGHT @@ -873,8 +1031,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 @@ -899,9 +1061,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 @@ -941,14 +1107,15 @@ class LightCommandRequest final : public CommandProtoMessage { uint32_t flash_length{0}; bool has_effect{false}; StringRef effect{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_SENSOR @@ -959,13 +1126,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 @@ -981,8 +1152,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 @@ -999,9 +1174,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 @@ -1011,13 +1190,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 @@ -1032,13 +1216,15 @@ class SwitchCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("switch_command_request"); } #endif bool state{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_TEXT_SENSOR @@ -1049,9 +1235,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 @@ -1065,10 +1255,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 @@ -1085,12 +1279,15 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { #endif enums::LogLevel level{}; bool dump_config{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SubscribeLogsResponse final : public ProtoMessage { public: @@ -1106,8 +1303,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 @@ -1124,12 +1325,15 @@ class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { #endif const uint8_t *key{nullptr}; uint16_t key_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class NoiseEncryptionSetKeyResponse final : public ProtoMessage { public: @@ -1139,8 +1343,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 @@ -1151,10 +1359,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 @@ -1168,7 +1380,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); } #endif - StringRef service{}; + StringRef service{nullptr, 0}; // null until set, encode only FixedVector data{}; FixedVector data_template{}; FixedVector variables{}; @@ -1180,10 +1392,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 @@ -1206,13 +1422,15 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { const uint8_t *response_data{nullptr}; uint16_t response_data_len{0}; #endif + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_API_HOMEASSISTANT_STATES @@ -1223,11 +1441,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 @@ -1244,12 +1466,15 @@ class HomeAssistantStateResponse final : public ProtoDecodableMessage { StringRef entity_id{}; StringRef state{}; StringRef attribute{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif class GetTimeRequest final : public ProtoMessage { @@ -1273,12 +1498,15 @@ class DSTRule final : public ProtoDecodableMessage { uint32_t month{0}; uint32_t week{0}; uint32_t day_of_week{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ParsedTimezone final : public ProtoDecodableMessage { public: @@ -1286,13 +1514,15 @@ class ParsedTimezone final : public ProtoDecodableMessage { int32_t dst_offset_seconds{0}; DSTRule dst_start{}; DSTRule dst_end{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class GetTimeResponse final : public ProtoDecodableMessage { public: @@ -1304,27 +1534,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 @@ -1338,15 +1574,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 @@ -1370,9 +1610,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class ExecuteServiceRequest final : public ProtoDecodableMessage { public: @@ -1395,9 +1633,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES @@ -1410,13 +1646,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 @@ -1432,8 +1672,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 @@ -1454,8 +1698,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 @@ -1471,12 +1719,15 @@ class CameraImageRequest final : public ProtoDecodableMessage { #endif bool single{false}; bool stream{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_CLIMATE @@ -1506,8 +1757,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 @@ -1517,7 +1772,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { class ClimateStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 47; - static constexpr uint8_t ESTIMATED_SIZE = 68; + static constexpr uint8_t ESTIMATED_SIZE = 71; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("climate_state_response"); } #endif @@ -1529,13 +1784,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 @@ -1569,14 +1829,15 @@ class ClimateCommandRequest final : public CommandProtoMessage { StringRef custom_preset{}; bool has_target_humidity{false}; float target_humidity{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_WATER_HEATER @@ -1593,8 +1854,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 @@ -1604,7 +1869,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { class WaterHeaterStateResponse final : public StateResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 133; - static constexpr uint8_t ESTIMATED_SIZE = 35; + static constexpr uint8_t ESTIMATED_SIZE = 37; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("water_heater_state_response"); } #endif @@ -1614,8 +1879,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 @@ -1635,13 +1905,15 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage { uint32_t state{0}; float target_temperature_low{0.0f}; float target_temperature_high{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_NUMBER @@ -1655,11 +1927,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 @@ -1675,8 +1951,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 @@ -1691,13 +1971,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 @@ -1708,9 +1990,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 @@ -1724,10 +2010,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 @@ -1742,14 +2032,15 @@ class SelectCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("select_command_request"); } #endif StringRef state{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_SIREN @@ -1763,8 +2054,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 @@ -1779,8 +2074,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 @@ -1802,14 +2101,15 @@ class SirenCommandRequest final : public CommandProtoMessage { uint32_t duration{0}; bool has_volume{false}; float volume{0.0f}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_LOCK @@ -1823,9 +2123,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 @@ -1840,8 +2144,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 @@ -1858,14 +2166,15 @@ class LockCommandRequest final : public CommandProtoMessage { enums::LockCommand command{}; bool has_code{false}; StringRef code{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_BUTTON @@ -1876,9 +2185,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 @@ -1892,25 +2205,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 @@ -1926,8 +2245,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 @@ -1944,8 +2267,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 @@ -1967,14 +2294,15 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage { StringRef media_url{}; bool has_announcement{false}; bool announcement{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_BLUETOOTH_PROXY @@ -1986,12 +2314,15 @@ class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMes const LogString *message_name() const override { return LOG_STR("subscribe_bluetooth_le_advertisements_request"); } #endif uint32_t flags{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothLERawAdvertisement final : public ProtoMessage { public: @@ -2015,8 +2346,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 @@ -2036,12 +2371,15 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage { enums::BluetoothDeviceRequestType request_type{}; bool has_address_type{false}; uint32_t address_type{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: @@ -2054,8 +2392,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 @@ -2070,20 +2412,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 @@ -2097,8 +2446,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 @@ -2111,8 +2464,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 @@ -2128,8 +2485,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 @@ -2144,8 +2505,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 @@ -2161,12 +2526,15 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { #endif uint64_t address{0}; uint32_t handle{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTReadResponse final : public ProtoMessage { public: @@ -2183,8 +2551,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 @@ -2203,13 +2575,15 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { bool response{false}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2220,12 +2594,15 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { #endif uint64_t address{0}; uint32_t handle{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: @@ -2238,13 +2615,15 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { uint32_t handle{0}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: @@ -2256,12 +2635,15 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { uint64_t address{0}; uint32_t handle{0}; bool enable{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: @@ -2278,8 +2660,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 @@ -2296,8 +2682,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 @@ -2314,8 +2704,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 @@ -2331,8 +2725,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 @@ -2348,8 +2746,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 @@ -2366,8 +2768,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 @@ -2384,8 +2790,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 @@ -2402,8 +2812,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 @@ -2422,8 +2836,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 @@ -2438,12 +2856,15 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_set_mode_request"); } #endif enums::BluetoothScannerMode mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_VOICE_ASSISTANT @@ -2456,20 +2877,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 @@ -2484,12 +2912,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 @@ -2505,23 +2937,29 @@ class VoiceAssistantResponse final : public ProtoDecodableMessage { #endif uint32_t port{0}; bool error{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantEventData final : public ProtoDecodableMessage { public: StringRef name{}; StringRef value{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantEventResponse final : public ProtoDecodableMessage { public: @@ -2532,13 +2970,15 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { #endif enums::VoiceAssistantEvent event_type{}; std::vector data{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAudio final : public ProtoDecodableMessage { public: @@ -2552,15 +2992,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: @@ -2575,13 +3021,15 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { uint32_t total_seconds{0}; uint32_t seconds_left{0}; bool is_active{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: @@ -2594,13 +3042,15 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { StringRef text{}; StringRef preannounce_media_id{}; bool start_conversation{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: @@ -2610,8 +3060,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 @@ -2620,11 +3074,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 @@ -2640,13 +3098,15 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { uint32_t model_size{0}; StringRef model_hash{}; StringRef url{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { public: @@ -2656,12 +3116,15 @@ class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_request"); } #endif std::vector external_wake_words{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: @@ -2673,8 +3136,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 @@ -2689,12 +3156,15 @@ class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("voice_assistant_set_configuration"); } #endif std::vector active_wake_words{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_ALARM_CONTROL_PANEL @@ -2708,8 +3178,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 @@ -2724,8 +3198,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 @@ -2741,14 +3219,15 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage { #endif enums::AlarmControlPanelStateCommand command{}; StringRef code{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_TEXT @@ -2761,10 +3240,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 @@ -2778,10 +3261,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 @@ -2796,14 +3283,15 @@ class TextCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("text_command_request"); } #endif StringRef state{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_DATE @@ -2814,8 +3302,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 @@ -2833,8 +3325,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 @@ -2851,13 +3347,15 @@ class DateCommandRequest final : public CommandProtoMessage { uint32_t year{0}; uint32_t month{0}; uint32_t day{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_TIME @@ -2868,8 +3366,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 @@ -2887,8 +3389,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 @@ -2905,13 +3411,15 @@ class TimeCommandRequest final : public CommandProtoMessage { uint32_t hour{0}; uint32_t minute{0}; uint32_t second{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_EVENT @@ -2922,10 +3430,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 @@ -2939,9 +3451,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 @@ -2957,12 +3473,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 @@ -2978,8 +3498,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 @@ -2996,13 +3520,15 @@ class ValveCommandRequest final : public CommandProtoMessage { bool has_position{false}; float position{0.0f}; bool stop{false}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_DATETIME_DATETIME @@ -3013,8 +3539,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 @@ -3030,8 +3560,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 @@ -3046,13 +3580,15 @@ class DateTimeCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("date_time_command_request"); } #endif uint32_t epoch_seconds{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_UPDATE @@ -3063,9 +3599,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 @@ -3083,13 +3623,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 @@ -3104,13 +3648,15 @@ class UpdateCommandRequest final : public CommandProtoMessage { const LogString *message_name() const override { return LOG_STR("update_command_request"); } #endif enums::UpdateCommand command{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; #endif #ifdef USE_ZWAVE_PROXY @@ -3123,14 +3669,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: @@ -3142,15 +3695,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: @@ -3161,8 +3720,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 @@ -3180,8 +3743,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 @@ -3189,7 +3756,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; @@ -3207,14 +3774,15 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { uint16_t timings_length_{0}; uint16_t timings_count_{0}; uint32_t modulation{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_32bit(uint32_t field_id, Proto32Bit value) override; - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class InfraredRFReceiveEvent final : public ProtoMessage { public: @@ -3228,8 +3796,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 @@ -3249,8 +3845,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 @@ -3272,12 +3872,15 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage { enums::SerialProxyParity parity{}; uint32_t stop_bits{0}; uint32_t data_size{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyDataReceived final : public ProtoMessage { public: @@ -3293,8 +3896,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 @@ -3311,13 +3918,15 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage { uint32_t instance{0}; const uint8_t *data{nullptr}; uint16_t data_len{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3328,12 +3937,15 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { #endif uint32_t instance{0}; uint32_t line_states{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { public: @@ -3343,12 +3955,15 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_request"); } #endif uint32_t instance{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: @@ -3360,8 +3975,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 @@ -3377,12 +3996,15 @@ class SerialProxyRequest final : public ProtoDecodableMessage { #endif uint32_t instance{0}; enums::SerialProxyRequestType type{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class SerialProxyRequestResponse final : public ProtoMessage { public: @@ -3394,9 +4016,76 @@ 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 + + protected: +}; +class SerialProxySetModeRequest final : public ProtoDecodableMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 152; + static constexpr uint8_t ESTIMATED_SIZE = 6; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); } +#endif + uint32_t instance{0}; + enums::SerialProxyMode mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); +}; +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 @@ -3417,12 +4106,15 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { uint32_t max_interval{0}; uint32_t latency{0}; uint32_t timeout{0}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class BluetoothSetConnectionParamsResponse final : public ProtoMessage { public: @@ -3433,8 +4125,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 diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index dced81ee30..966ba8f2bf 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"); } @@ -854,6 +856,8 @@ template<> const char *proto_enum_to_string(enums return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE"); case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS: return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS"); + case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE: + return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -878,13 +882,50 @@ template<> const char *proto_enum_to_string(enums::Ser return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyMode value) { + switch (value) { + case enums::SERIAL_PROXY_MODE_RAW: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW"); + case enums::SERIAL_PROXY_MODE_PROTOCOL: + return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} +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 { @@ -1008,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(); } @@ -1034,6 +1078,13 @@ const char *ZWaveProxyCapabilities::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 @@ -1057,9 +1108,29 @@ const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { it.dump_to(out); 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 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(); @@ -1330,6 +1401,7 @@ const char *SwitchStateResponse::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *SwitchCommandRequest::dump_to(DumpBuffer &out) const { @@ -1672,6 +1744,7 @@ const char *ClimateStateResponse::dump_to(DumpBuffer &out) const { #ifdef USE_DEVICES dump_field(out, ESPHOME_PSTR("device_id"), this->device_id); #endif + dump_field(out, ESPHOME_PSTR("missing_state"), this->missing_state); return out.c_str(); } const char *ClimateCommandRequest::dump_to(DumpBuffer &out) const { @@ -1739,6 +1812,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 { @@ -2699,7 +2773,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 @@ -2728,6 +2802,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 { @@ -2805,6 +2888,33 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error_message"), this->error_message); return out.c_str(); } +const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); + return out.c_str(); +} +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 70ba579fcc..833e6529e2 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 65c7b8858c..1ef70f1fd4 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); @@ -712,6 +712,46 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui this->on_device_capabilities_request(); break; } +#ifdef USE_SERIAL_PROXY + case SerialProxySetModeRequest::MESSAGE_TYPE: { + SerialProxySetModeRequest msg; + msg.decode(msg_data, msg_size); +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg); +#endif + this->on_serial_proxy_set_mode_request(msg); + break; + } +#endif +#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 default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6abdf7093e..13fc03e03e 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 @@ -235,6 +242,13 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; +#endif +#ifdef USE_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 d74de98c75..33869dd976 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" @@ -57,12 +56,15 @@ 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 @@ -75,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( @@ -157,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() { @@ -165,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). @@ -181,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(); @@ -210,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]; @@ -225,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(); @@ -243,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 @@ -267,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(); @@ -276,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, @@ -304,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) {} @@ -426,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{}; @@ -439,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 @@ -455,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")); } @@ -599,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; } }); } @@ -700,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 4aa0a422dd..09b08de4e3 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 @@ -49,8 +50,7 @@ struct SavedNoisePsk { 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 @@ -91,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); @@ -188,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 @@ -268,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); @@ -308,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}; @@ -319,7 +339,7 @@ class APIServer final : public Component, #endif // 4-byte aligned types - uint32_t reboot_timeout_{300000}; + uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t last_connected_{0}; // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr. @@ -356,18 +376,23 @@ class APIServer final : public Component, #endif // Group smaller types together - uint16_t port_{6053}; - uint16_t batch_delay_{100}; - // 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}; + 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 + 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 @@ -375,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 0000000000..ec270ab411 --- /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 0000000000..948b9445f2 --- /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 9e0faf9881..e7798a5d26 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 236e4a474a..6738d5d8b7 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 a226e080e8..5f2a90d547 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 6b1ae9651d..36e96e1081 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 0000000000..314b907e4d --- /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 b727b8fdb9..f03213c8ca 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 847b89f121..4cf1e95f69 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 f70c5e999f..3afab8422f 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 10cbc981c7..853ff206c2 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 193e62f615..a29e166745 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 594395e96d..df50d5142f 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 a732d2bcc7..0000000000 --- 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 184b2e8733..cde1a41f65 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 87db214233..5a6b667535 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 38b24c7cf6..e3d5fa2de2 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 30585cb873..6b959c19fa 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 2a5304be77..1480faa8c3 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,15 +334,11 @@ 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() - add_idf_component( - name="esphome/esp-audio-libs", - ref="3.2.1", - ) + add_idf_component(name="esphome/esp-audio-libs", ref="4.0.1") data = _get_data() diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index e69f33ac2d..9f2960620e 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 b1f76172b0..db85185ca6 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 c2bdfb6cb0..7d4b51db04 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 fc7af25622..0000000000 --- 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 1351793afd..fbaa72014f 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 16a422f4ac..aebdd85540 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 9c5348271c..0000000000 --- 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 d59ed7411a..7726ef5837 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 537e19fb3c..177374cf91 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 d269f77c35..12735eaca1 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 fb8620f7d9..6cea4d7311 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 a97025e53e..e75dcf7a67 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 14543957e9..3c23517b92 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/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index 5dfb121342..a1104aa1b2 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -203,16 +203,10 @@ void BangBangClimate::set_away_config(const BangBangClimateTargetTempConfig &awa this->away_config_ = away_config; } -void BangBangClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } -void BangBangClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } - Trigger<> *BangBangClimate::get_idle_trigger() { return &this->idle_trigger_; } Trigger<> *BangBangClimate::get_cool_trigger() { return &this->cool_trigger_; } Trigger<> *BangBangClimate::get_heat_trigger() { return &this->heat_trigger_; } -void BangBangClimate::set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } -void BangBangClimate::set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } - void BangBangClimate::dump_config() { LOG_CLIMATE("", "Bang Bang Climate", this); ESP_LOGCONFIG(TAG, diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h index d83257f9f3..fff9bf873f 100644 --- a/esphome/components/bang_bang/bang_bang_climate.h +++ b/esphome/components/bang_bang/bang_bang_climate.h @@ -22,10 +22,10 @@ class BangBangClimate final : public climate::Climate, public Component { void setup() override; void dump_config() override; - void set_sensor(sensor::Sensor *sensor); - void set_humidity_sensor(sensor::Sensor *humidity_sensor); - void set_supports_cool(bool supports_cool); - void set_supports_heat(bool supports_heat); + void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } + void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } + void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_normal_config(const BangBangClimateTargetTempConfig &normal_config); void set_away_config(const BangBangClimateTargetTempConfig &away_config); diff --git a/esphome/components/bedjet/sensor/__init__.py b/esphome/components/bedjet/sensor/__init__.py index 595e798e49..9319af664f 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/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h index 32707e8b0c..b8de7932cd 100644 --- a/esphome/components/binary/light/binary_light_output.h +++ b/esphome/components/binary/light/binary_light_output.h @@ -17,11 +17,7 @@ class BinaryLightOutput final : public light::LightOutput { void write_state(light::LightState *state) override { bool binary; state->current_values_as_binary(&binary); - if (binary) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(binary); } protected: diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index 9ef7efc96a..f5cde2c8e6 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -1,7 +1,7 @@ from logging import getLogger from esphome import automation, core -from esphome.automation import Condition, maybe_simple_id +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import mqtt, web_server, zigbee from esphome.components.const import CONF_ON_STATE_CHANGE @@ -39,6 +39,7 @@ from esphome.const import ( DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, @@ -60,14 +61,16 @@ from esphome.const import ( DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import ( + SubEntities, entity_duplicate_validator, queue_entity_register, setup_device_class, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -81,6 +84,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_GAS, + DEVICE_CLASS_GLASS_BREAK, DEVICE_CLASS_HEAT, DEVICE_CLASS_LIGHT, DEVICE_CLASS_LOCK, @@ -132,15 +136,6 @@ MultiClickTriggerBase = binary_sensor_ns.class_( MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -BinarySensorPublishAction = binary_sensor_ns.class_( - "BinarySensorPublishAction", automation.Action -) -BinarySensorInvalidateAction = binary_sensor_ns.class_( - "BinarySensorInvalidateAction", automation.Action -) - -# Condition -BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Condition) # Filters Filter = binary_sensor_ns.class_("Filter") @@ -639,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), @@ -646,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) @@ -667,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 d5a85ca9c4..49457200bc 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -18,26 +18,6 @@ struct MultiClickTriggerEvent { uint32_t max_length; }; -class PressTrigger final : public Trigger<> { - public: - explicit PressTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { - if (state) - this->trigger(); - }); - } -}; - -class ReleaseTrigger final : public Trigger<> { - public: - explicit ReleaseTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { - if (!state) - this->trigger(); - }); - } -}; - bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); class ClickTrigger final : public Trigger<> { @@ -140,53 +120,4 @@ template class MultiClickTrigger final : public MultiClickTriggerBase std::array timing_storage_{}; }; -class StateTrigger final : public Trigger { - public: - explicit StateTrigger(BinarySensor *parent) { - parent->add_on_state_callback([this](bool state) { this->trigger(state); }); - } -}; - -class StateChangeTrigger final : public Trigger, optional > { - public: - explicit StateChangeTrigger(BinarySensor *parent) { - parent->add_full_state_callback( - [this](optional old_state, optional state) { this->trigger(old_state, state); }); - } -}; - -template class BinarySensorCondition final : public Condition { - public: - BinarySensorCondition(BinarySensor *parent, bool state) : parent_(parent), state_(state) {} - bool check(const Ts &...x) override { return this->parent_->state == this->state_; } - - protected: - BinarySensor *parent_; - bool state_; -}; - -template class BinarySensorPublishAction final : public Action { - public: - explicit BinarySensorPublishAction(BinarySensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(bool, state) - - void play(const Ts &...x) override { - auto val = this->state_.value(x...); - this->sensor_->publish_state(val); - } - - protected: - BinarySensor *sensor_; -}; - -template class BinarySensorInvalidateAction final : public Action { - public: - explicit BinarySensorInvalidateAction(BinarySensor *sensor) : sensor_(sensor) {} - - void play(const Ts &...x) override { this->sensor_->invalidate_state(); } - - protected: - BinarySensor *sensor_; -}; - } // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 28c156763a..a96113b520 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -32,7 +32,8 @@ void log_binary_sensor(const char *tag, const char *prefix, const char *type, Bi */ class BinarySensor : public StatefulEntityBase { public: - explicit BinarySensor() = default; + // User provided, not "= default": `new(p) BinarySensor()` would zero-fill .bss that is already zero. + explicit BinarySensor() {} const bool &get_state() const override { return this->state; } void set_trigger_on_initial_state(bool value) { this->trigger_on_initial_state_ = value; } diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 6887de35e1..1ec255d63d 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -53,6 +53,9 @@ class DelayedOnOffFilter final : public Filter { class DelayedOnFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOnFilter()` would zero-fill .bss that is already zero. + DelayedOnFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } @@ -63,6 +66,9 @@ class DelayedOnFilter : public Filter { class DelayedOffFilter : public Filter { public: + // User provided, not "= default": `new(p) DelayedOffFilter()` would zero-fill .bss that is already zero. + DelayedOffFilter() {} + optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } @@ -143,6 +149,8 @@ class StatelessLambdaFilter : public Filter { class SettleFilter : public Filter { public: + // User provided, not "= default": `new(p) SettleFilter()` would zero-fill .bss that is already zero. + SettleFilter() {} optional new_value(bool value) override; template void set_delay(T delay) { this->delay_ = delay; } diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 96b3536601..55013d4087 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 9017d19d71..f785b3eb3a 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 54de9f9b0c..93ccf09521 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 1a0c2287ab..25c50555db 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 ec17ef2c7e..9f6bd7814e 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 7e6403c3bc..1cf7e8e6e3 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 5531fe411b..397471cf7a 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 1ef7967fa8..810f4b66e3 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 ba3128c0ee..a50813c585 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 6acc4edb92..7cc87add5a 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 c0837da402..40983c4dbb 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 986a67c7a8..d3af02780f 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 f285260e76..ef0ca223cb 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -31,6 +31,9 @@ class BluetoothConnection; // void disconnect() cannot overload with an int-returning twin. class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public Component { public: + // User provided, not "= default": `new(p) BluedroidGattClient()` would zero-fill .bss that is already zero. + BluedroidGattClient() {} + static constexpr uint16_t UNSET_CONN_ID = 0xFFFF; // Lifecycle of one connection attempt's service search. @@ -97,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, @@ -130,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/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index 47181e81a7..4c87b876c3 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -37,6 +37,9 @@ enum class PendingAck : uint8_t { class BluetoothConnection final : public ble_device_base::GattClientListener { public: + // User provided, not "= default": `new(p) BluetoothConnection()` would zero-fill .bss that is already zero. + BluetoothConnection() {} + /// Wire the platform backend. Called from codegen before setup. void set_backend(ble_device_base::BLEGattConnection *backend) { this->backend_ = backend; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index 16a89dcfdd..eec2c8c318 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -626,7 +626,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { // explicit kick the MTU would only be exchanged on the first GATT query, // which never happens on a V3_WITH_CACHE connection. // Both registration calls above return void (BTstack 075a078, arduino-pico - // 6.0.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by + // 6.1.0); failures surface as a missing GATT_EVENT_MTU and are reclaimed by // the connect timeout in loop(). gatt_client_send_mtu_negotiation(&RP2GattClient::gatt_packet_handler, this->con_handle_); } diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index 1b761849a5..c87ad7f595 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -395,6 +395,17 @@ async def _to_code_ble_hub(config: ConfigType) -> None: await _connections_to_code(var, config) +def enable_advertisement_filter() -> None: + """Compile the advertisement filter hook into bluetooth_proxy. + + Called by external filtering components from to_code(). The define behind + this is an implementation detail; do not emit it directly. + + Public API for external components. Do not remove. + """ + cg.add_define("USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER") + + async def to_code(config: ConfigType) -> None: if CORE.is_esp32: await _to_code_esp32(config) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 878d3cd44e..cb37057cd4 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -94,6 +94,15 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) return; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + // Ask the filter before the packet is queued, so a dropped advertisement never + // reaches the batch or the network. + if (this->advertisement_filter_.is_set() && !this->advertisement_filter_.should_forward(raw)) { + ESP_LOGVV(TAG, "Filtered packet from %012" PRIX64, raw.address); + return; + } +#endif + auto &adv = this->response_.advertisements[this->response_.advertisements_len]; adv.address = raw.address; adv.rssi = raw.rssi; @@ -184,6 +193,9 @@ void BluetoothProxy::dump_config() { " Adapter MAC: %s", scan_mode, mac_out); #endif +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + ESP_LOGCONFIG(TAG, " Advertisement filter: %s", YESNO(this->advertisement_filter_.is_set())); +#endif } #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index e233c38b56..567109dc60 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -97,6 +97,29 @@ static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x000011223344556 static_assert(PendingReply{}.empty()); #endif +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER +/// Predicate slot letting an external component drop advertisements before they +/// are queued for the API. Same shape as +/// ble_device_base::RawAdvertisementCallback. Runs on the advertisement hot +/// path, so it must be cheap and must not block. +/// +/// Usage: +/// proxy->set_advertisement_filter({this, [](void *self, const ble_device_base::RawAdvertisement &adv) { +/// return static_cast(self)->should_forward(adv); +/// }}); +/// +/// Returning false drops the advertisement. Not called at all while the API is +/// disconnected, which matters to a stateful filter. Compiled in only when an +/// external component calls bluetooth_proxy.enable_advertisement_filter(). +struct AdvertisementFilter { + void *instance{nullptr}; + bool (*fn)(void *instance, const ble_device_base::RawAdvertisement &adv){nullptr}; + /// A default-constructed slot is "no filter"; the proxy guards on this. + bool is_set() const { return this->fn != nullptr; } + bool should_forward(const ble_device_base::RawAdvertisement &adv) const { return this->fn(this->instance, adv); } +}; +#endif // USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + class BluetoothProxy final : public Component { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Allow the connection to update connections_free_response_ @@ -162,6 +185,11 @@ class BluetoothProxy final : public Component { void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + /// One subscriber; a later call replaces an earlier one. + void set_advertisement_filter(AdvertisementFilter filter) { this->advertisement_filter_ = filter; } +#endif + uint32_t get_legacy_version() const { if (!this->active_) { return LEGACY_PASSIVE_ONLY_VERSION; @@ -330,6 +358,10 @@ class BluetoothProxy final : public Component { // start on an even word, closing two alignment holes. uint32_t last_advertisement_flush_time_{0}; +#ifdef USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER + AdvertisementFilter advertisement_filter_{}; +#endif + // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h index 5ca9714091..b9b7a2c327 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 5ef162bb7c..c28976eb87 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 287946801e..4b079def64 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -90,20 +90,14 @@ async def to_code_base(config: ConfigType) -> MockObj: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING])) - - if pressure_config := config.get(CONF_PRESSURE): - sens = await sensor.new_sensor(pressure_config) - cg.add(var.set_pressure_sensor(sens)) - cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING])) - - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING])) + sensors = sensor.sub_sensors(config) + if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor): + oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING] + cg.add(var.set_temperature_oversampling(oversampling)) + if await sensors(CONF_PRESSURE, var.set_pressure_sensor): + cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING])) + if await sensors(CONF_HUMIDITY, var.set_humidity_sensor): + cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING])) cg.add(var.set_iir_filter(config[CONF_IIR_FILTER])) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 0f7e42cce3..11c796352a 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -341,7 +341,6 @@ void BME280Component::set_pressure_oversampling(BME280Oversampling pressure_over void BME280Component::set_humidity_oversampling(BME280Oversampling humidity_over_sampling) { this->humidity_oversampling_ = humidity_over_sampling; } -void BME280Component::set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BME280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->read_byte(a_register, &data); diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h index 7fe5f7401d..8b4906b7b7 100644 --- a/esphome/components/bme280_base/bme280_base.h +++ b/esphome/components/bme280_base/bme280_base.h @@ -69,7 +69,7 @@ class BME280Component : public PollingComponent { /// Set the oversampling value for the humidity sensor. Default is 16x. void set_humidity_oversampling(BME280Oversampling humidity_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BME280IIRFilter iir_filter); + void set_iir_filter(BME280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index 164424de09..bac8ed8a5a 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -503,7 +503,6 @@ void BME680Component::set_pressure_oversampling(BME680Oversampling pressure_over void BME680Component::set_humidity_oversampling(BME680Oversampling humidity_oversampling) { this->humidity_oversampling_ = humidity_oversampling; } -void BME680Component::set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void BME680Component::set_heater(uint16_t heater_temperature, uint16_t heater_duration) { this->heater_temperature_ = heater_temperature; this->heater_duration_ = heater_duration; diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h index a274578fc1..e401d03659 100644 --- a/esphome/components/bme680/bme680.h +++ b/esphome/components/bme680/bme680.h @@ -74,7 +74,7 @@ class BME680Component final : public PollingComponent, public i2c::I2CDevice { /// Set the humidity oversampling value. Defaults to 16X. void set_humidity_oversampling(BME680Oversampling humidity_oversampling); /// Set the IIR Filter value. Defaults to no IIR Filter. - void set_iir_filter(BME680IIRFilter iir_filter); + void set_iir_filter(BME680IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { temperature_sensor_ = temperature_sensor; } void set_pressure_sensor(sensor::Sensor *pressure_sensor) { pressure_sensor_ = pressure_sensor; } diff --git a/esphome/components/bme680/sensor.py b/esphome/components/bme680/sensor.py index dce5c88cfa..ffe5c74bbd 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 4309f0a79f..9ad1b19fa0 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 e4e559844e..5eb8dae731 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 c0f0ae90bf..a956ed34f6 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/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 1dae5a689e..34e1d67101 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -254,7 +254,6 @@ void BMP280Component::set_temperature_oversampling(BMP280Oversampling temperatur void BMP280Component::set_pressure_oversampling(BMP280Oversampling pressure_over_sampling) { this->pressure_oversampling_ = pressure_over_sampling; } -void BMP280Component::set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } uint8_t BMP280Component::read_u8_(uint8_t a_register) { uint8_t data = 0; this->bmp_read_byte(a_register, &data); diff --git a/esphome/components/bmp280_base/bmp280_base.h b/esphome/components/bmp280_base/bmp280_base.h index 3bf1edab04..860fff6b4b 100644 --- a/esphome/components/bmp280_base/bmp280_base.h +++ b/esphome/components/bmp280_base/bmp280_base.h @@ -59,7 +59,7 @@ class BMP280Component : public PollingComponent { /// Set the oversampling value for the pressure sensor. Default is 16x. void set_pressure_oversampling(BMP280Oversampling pressure_over_sampling); /// Set the IIR Filter used to increase accuracy, defaults to no IIR Filter. - void set_iir_filter(BMP280IIRFilter iir_filter); + void set_iir_filter(BMP280IIRFilter iir_filter) { this->iir_filter_ = iir_filter; } void setup() override; void dump_config() override; diff --git a/esphome/components/bmp3xx_base/__init__.py b/esphome/components/bmp3xx_base/__init__.py index 75e168378e..6d0618e136 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 1c2c5c37d4..5d031a8208 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/bridge/__init__.py b/esphome/components/bridge/__init__.py new file mode 100644 index 0000000000..49811b0181 --- /dev/null +++ b/esphome/components/bridge/__init__.py @@ -0,0 +1,4 @@ +CODEOWNERS = ["@kbx81"] +DOMAIN = "bridge" + +IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 1ebabea0a3..51f5d866a4 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 f559d0aa9b..86ff34a349 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 ee24002b8a..342f451509 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 d55d43ea37..0000000000 --- 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 01e3ed0cd5..5de39a16ce 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -351,10 +351,6 @@ async def to_code(config): # Actions -BeginTxAction = ns.class_("BeginTxAction", automation.Action) -BeginRxAction = ns.class_("BeginRxAction", automation.Action) -ResetAction = ns.class_("ResetAction", automation.Action) -SetIdleAction = ns.class_("SetIdleAction", automation.Action) SendPacketAction = ns.class_( "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) ) @@ -364,22 +360,15 @@ CC1101_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA, synchronous=True -) -async def cc1101_action_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("cc1101.begin_tx", "begin_tx()"), + ("cc1101.begin_rx", "begin_rx()"), + ("cc1101.reset", "reset()"), + ("cc1101.set_idle", "set_idle()"), +): + automation.register_apply_action( + _name, CC1101_ACTION_SCHEMA, automation.ApplyCall(_call) + ) def validate_raw_data(value): @@ -424,91 +413,41 @@ async def send_packet_action_to_code(config, action_id, template_arg, args): return var -# Setter action definitions: (setter_name, validator, template_type, enum_map) -_SETTER_ACTIONS = [ - ( - "set_frequency", - cv.All(cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)), - float, - None, - ), - ("set_output_power", cv.float_range(min=-30.0, max=11.0), float, None), - ("set_modulation_type", cv.enum(MODULATION, upper=False), Modulation, MODULATION), - ("set_symbol_rate", cv.float_range(min=600, max=500000), float, None), - ( - "set_rx_attenuation", - cv.enum(RX_ATTENUATION, upper=False), - RxAttenuation, - RX_ATTENUATION, - ), - ("set_dc_blocking_filter", cv.boolean, bool, None), - ("set_manchester", cv.boolean, bool, None), - ( - "set_filter_bandwidth", - cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), - float, - None, - ), - ( - "set_fsk_deviation", - cv.All(cv.frequency, cv.float_range(min=1500, max=381000)), - float, - None, - ), - ("set_msk_deviation", cv.int_range(min=1, max=8), cg.uint8, None), - ("set_channel", cv.uint8_t, cg.uint8, None), - ( - "set_channel_spacing", - cv.All(cv.frequency, cv.float_range(min=25000, max=405000)), - float, - None, - ), - ( - "set_if_frequency", - cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), - float, - None, - ), -] +_CONFIG_VALIDATORS = {opt.schema: validator for opt, validator in CONFIG_MAP.items()} + +# Each key has a set_ action validated exactly like the config option. +_SETTER_ACTIONS = ( + (CONF_FREQUENCY, cg.float_), + (CONF_OUTPUT_POWER, cg.float_), + (CONF_MODULATION_TYPE, Modulation), + (CONF_SYMBOL_RATE, cg.float_), + (CONF_RX_ATTENUATION, RxAttenuation), + (CONF_DC_BLOCKING_FILTER, cg.bool_), + (CONF_MANCHESTER, cg.bool_), + (CONF_FILTER_BANDWIDTH, cg.float_), + (CONF_FSK_DEVIATION, cg.float_), + (CONF_MSK_DEVIATION, cg.uint8), + (CONF_CHANNEL, cg.uint8), + (CONF_CHANNEL_SPACING, cg.float_), + (CONF_IF_FREQUENCY, cg.float_), +) -def _register_setter_actions(): - for setter_name, validator, templ_type, enum_map in _SETTER_ACTIONS: - class_name = ( - "".join(word.capitalize() for word in setter_name.split("_")) + "Action" +def _register_setter_actions() -> None: + for conf_key, value_type in _SETTER_ACTIONS: + automation.register_apply_action( + f"cc1101.set_{conf_key}", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_VALUE): cv.templatable( + _CONFIG_VALIDATORS[conf_key] + ), + }, + key=CONF_VALUE, + ), + automation.ApplyField(CONF_VALUE, f"set_{conf_key}", value_type), ) - action_cls = ns.class_( - class_name, automation.Action, cg.Parented.template(CC1101Component) - ) - schema = cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(CC1101Component), - cv.Required(CONF_VALUE): cv.templatable(validator), - }, - key=CONF_VALUE, - ) - - async def _setter_action_to_code( - config, - action_id, - template_arg, - args, - _setter=setter_name, - _type=templ_type, - _map=enum_map, - ): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_VALUE] - if _map and not cg.is_template(data): - data = _map[data] - templ_ = await cg.templatable(data, args, _type) - cg.add(getattr(var, _setter)(templ_)) - return var - - automation.register_action( - f"cc1101.{setter_name}", action_cls, schema, synchronous=True - )(_setter_action_to_code) _register_setter_actions() diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 79bfc9cb33..499f77a809 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -130,26 +130,6 @@ class CC1101Component final : public Component, }; // Action Wrappers -template class BeginTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_tx(); } -}; - -template class BeginRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_rx(); } -}; - -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class SetIdleAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_idle(); } -}; - template class SendPacketAction final : public Action, public Parented { public: void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } @@ -174,83 +154,4 @@ template class SendPacketAction final : public Action, pu size_t data_static_len_{0}; }; -template class SetSymbolRateAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, symbol_rate) - void play(const Ts &...x) override { this->parent_->set_symbol_rate(this->symbol_rate_.value(x...)); } -}; - -template class SetFrequencyAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, frequency) - void play(const Ts &...x) override { this->parent_->set_frequency(this->frequency_.value(x...)); } -}; - -template class SetOutputPowerAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, output_power) - void play(const Ts &...x) override { this->parent_->set_output_power(this->output_power_.value(x...)); } -}; - -template class SetModulationTypeAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(Modulation, modulation_type) - void play(const Ts &...x) override { this->parent_->set_modulation_type(this->modulation_type_.value(x...)); } -}; - -template class SetRxAttenuationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(RxAttenuation, rx_attenuation) - void play(const Ts &...x) override { this->parent_->set_rx_attenuation(this->rx_attenuation_.value(x...)); } -}; - -template -class SetDcBlockingFilterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, dc_blocking_filter) - void play(const Ts &...x) override { this->parent_->set_dc_blocking_filter(this->dc_blocking_filter_.value(x...)); } -}; - -template class SetManchesterAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(bool, manchester) - void play(const Ts &...x) override { this->parent_->set_manchester(this->manchester_.value(x...)); } -}; - -template class SetFilterBandwidthAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, filter_bandwidth) - void play(const Ts &...x) override { this->parent_->set_filter_bandwidth(this->filter_bandwidth_.value(x...)); } -}; - -template class SetFskDeviationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, fsk_deviation) - void play(const Ts &...x) override { this->parent_->set_fsk_deviation(this->fsk_deviation_.value(x...)); } -}; - -template class SetMskDeviationAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, msk_deviation) - void play(const Ts &...x) override { this->parent_->set_msk_deviation(this->msk_deviation_.value(x...)); } -}; - -template class SetChannelAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(uint8_t, channel) - void play(const Ts &...x) override { this->parent_->set_channel(this->channel_.value(x...)); } -}; - -template class SetChannelSpacingAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, channel_spacing) - void play(const Ts &...x) override { this->parent_->set_channel_spacing(this->channel_spacing_.value(x...)); } -}; - -template class SetIfFrequencyAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(float, if_frequency) - void play(const Ts &...x) override { this->parent_->set_if_frequency(this->if_frequency_.value(x...)); } -}; - } // namespace esphome::cc1101 diff --git a/esphome/components/ccs811/sensor.py b/esphome/components/ccs811/sensor.py index d134d2cf21..95e51c825a 100644 --- a/esphome/components/ccs811/sensor.py +++ b/esphome/components/ccs811/sensor.py @@ -65,17 +65,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if eco2_config := config.get(CONF_ECO2): - sens = await sensor.new_sensor(eco2_config) - cg.add(var.set_co2(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ECO2, var.set_co2) + await sensors(CONF_TVOC, var.set_tvoc) - if tvoc_config := config.get(CONF_TVOC): - sens = await sensor.new_sensor(tvoc_config) - cg.add(var.set_tvoc(sens)) - - if version_config := config.get(CONF_VERSION): - sens = await text_sensor.new_text_sensor(version_config) - cg.add(var.set_version(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_VERSION, var.set_version) if (baseline := config.get(CONF_BASELINE)) is not None: cg.add(var.set_baseline(baseline)) diff --git a/esphome/components/cdc_acm_uart/__init__.py b/esphome/components/cdc_acm_uart/__init__.py new file mode 100644 index 0000000000..516af84856 --- /dev/null +++ b/esphome/components/cdc_acm_uart/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@kbx81"] diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py new file mode 100644 index 0000000000..cee048df5d --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -0,0 +1,114 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, uart, usb_cdc_acm +from esphome.components.bridge import DOMAIN as BRIDGE_DOMAIN +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DEPENDENCIES = ["tinyusb", "uart", "usb_cdc_acm"] + +CONF_DTR_PIN = "dtr_pin" +CONF_RTS_PIN = "rts_pin" +CONF_USB_CDC_ACM_ID = "usb_cdc_acm_id" + +cdc_acm_uart_ns = cg.esphome_ns.namespace("cdc_acm_uart") +CDCACMUARTBridge = cdc_acm_uart_ns.class_("CDCACMUARTBridge", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CDCACMUARTBridge), + cv.Required(CONF_UART_ID): cv.use_id(uart.IDFUARTComponent), + cv.Required(CONF_USB_CDC_ACM_ID): cv.use_id(usb_cdc_acm.USBCDCACMInstance), + cv.Optional(CONF_DTR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RTS_PIN): pins.gpio_output_pin_schema, + } + ).extend(cv.COMPONENT_SCHEMA), + # Narrower than usb_cdc_acm's variant list on purpose: S31/H4 untested on + # hardware; extend once verified. + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _subtree_references_uart(node: object, uart_id: str) -> bool: + """Return True if any dict in the subtree has a uart_id entry naming this bus.""" + if isinstance(node, dict): + return any( + (key == CONF_UART_ID and str(value) == uart_id) + or _subtree_references_uart(value, uart_id) + for key, value in node.items() + ) + if isinstance(node, list): + return any(_subtree_references_uart(item, uart_id) for item in node) + return False + + +def _reject_debug(uart_conf: ConfigType) -> ConfigType: + # The worker tasks use the IDF driver directly, so the uart debugger never sees + # bridge traffic and its dummy_receiver would drain RX bytes on the main loop. + if CONF_DEBUG in uart_conf: + raise cv.Invalid( + "A bridged UART cannot use 'debug'; the bridge bypasses the UART " + "component's read/write path.", + [CONF_DEBUG], + ) + return uart_conf + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + # Bridges of any platform must own their interfaces exclusively; shared ring + # buffers and overwritten callbacks would corrupt both streams silently. The + # seen-set is keyed on the bridge domain so future platforms share it. + # Other components bind either interface through the same uart_id key (the CDC + # instance is itself a uart::UARTComponent) and would race the worker tasks. + # Bare `id:` references (a uart.write action) cannot be distinguished; not caught. + data = full_config.data.setdefault(BRIDGE_DOMAIN, {}) + for conf_key, label in ( + (CONF_UART_ID, "UART"), + (CONF_USB_CDC_ACM_ID, "USB CDC-ACM interface"), + ): + owned_id = str(config[conf_key]) + used = data.setdefault(conf_key, set()) + if owned_id in used: + raise cv.Invalid( + f"The {label} '{owned_id}' is already bridged by another 'bridge' " + f"instance; each bridge requires its own {label}.", + [conf_key], + ) + used.add(owned_id) + for domain, domain_conf in full_config.items(): + if domain == BRIDGE_DOMAIN: + continue + if _subtree_references_uart(domain_conf, owned_id): + raise cv.Invalid( + f"The {label} '{owned_id}' is also used by '{domain}'; a bridge " + f"requires exclusive use of its {label}.", + [conf_key], + ) + + fv.id_declaration_match_schema(_reject_debug)(config[CONF_UART_ID]) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + uart_component = await cg.get_variable(config[CONF_UART_ID]) + usb_cdc = await cg.get_variable(config[CONF_USB_CDC_ACM_ID]) + var = cg.new_Pvariable(config[CONF_ID], uart_component, usb_cdc) + await cg.register_component(var, config) + + if dtr_pin_config := config.get(CONF_DTR_PIN): + dtr_pin = await cg.gpio_pin_expression(dtr_pin_config) + cg.add(var.set_dtr_pin(dtr_pin)) + if rts_pin_config := config.get(CONF_RTS_PIN): + rts_pin = await cg.gpio_pin_expression(rts_pin_config) + cg.add(var.set_rts_pin(rts_pin)) diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp new file mode 100644 index 0000000000..042688bfe6 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp @@ -0,0 +1,468 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "cdc_acm_uart_bridge.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/ringbuf.h" +#include "driver/uart.h" +#include "soc/soc_caps.h" + +namespace esphome::cdc_acm_uart { + +static const char *const TAG = "cdc_acm_uart"; + +static constexpr size_t UART_TASK_STACK_SIZE = 4096; +static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64; +static constexpr uint32_t LOG_THROTTLE_MS = 1000; +static constexpr uint32_t UART_RELOAD_SETTLE_MS = 20; +// Above the default priority but below the USB/Wi-Fi system tasks. +static constexpr UBaseType_t TASK_PRIORITY = 4; + +static bool should_log_now(uint32_t *last_ms, uint32_t interval_ms) { + uint32_t now = millis(); + if ((now - *last_ms) >= interval_ms) { + *last_ms = now; + return true; + } + return false; +} + +static bool ringbuf_send_with_retry(RingbufHandle_t ringbuf, const uint8_t *data, size_t len, uint32_t *log_ms) { + if (len == 0) { + return true; + } + + if (xRingbufferSend(ringbuf, data, len, pdMS_TO_TICKS(1)) == pdTRUE) { + return true; + } + + size_t offset = 0; + while (offset < len) { + size_t chunk = std::min(RINGBUF_RETRY_CHUNK_SIZE, len - offset); + if (xRingbufferSend(ringbuf, data + offset, chunk, pdMS_TO_TICKS(1)) != pdTRUE) { + if (should_log_now(log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "USB TX buffer full; some data is lost"); + } + return false; + } + offset += chunk; + } + return true; +} + +void CDCACMUARTBridge::setup() { + // Line state starts deasserted (no host yet); active-low DTR#/RTS# wiring is + // handled by configuring the pins inverted, so deasserted idles HIGH. + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->setup(); + this->dtr_pin_->digital_write(false); + } + + if (this->rts_pin_ != nullptr) { + this->rts_pin_->setup(); + this->rts_pin_->digital_write(false); + } + + // A failed UART never assigned its port number, so the worker tasks would run + // against an indeterminate port. + if (this->uart_parent_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->configured_baud_rate_ = this->uart_parent_->get_baud_rate(); + this->configured_parity_ = this->uart_parent_->get_parity(); + this->configured_stop_bits_ = this->uart_parent_->get_stop_bits(); + this->configured_data_bits_ = this->uart_parent_->get_data_bits(); + + // usb_cdc_acm sets up first (priority IO > HARDWARE). Any interface failing marks + // the hub failed, and a failed hub no longer runs loop(), so line coding and line + // state events would never reach this bridge even if its own interface is healthy. + if (this->usb_cdc_parent_->get_parent()->is_failed()) { + ESP_LOGE(TAG, "USB CDC ACM failed; aborting"); + this->mark_failed(); + return; + } + + // Per-instance task names (keyed on the CDC interface number) keep task dumps + // unambiguous with multiple bridges. + char tx_task_name[] = "cdc_uart_tx_0"; + char rx_task_name[] = "cdc_uart_rx_0"; + const char itf_char = format_hex_char(this->usb_cdc_parent_->get_itf()); + tx_task_name[sizeof(tx_task_name) - 2] = itf_char; + rx_task_name[sizeof(rx_task_name) - 2] = itf_char; + + xTaskCreate(uart_tx_task_fn, tx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_tx_task_handle_); + if (this->uart_tx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART TX task"); + this->mark_failed(); + return; + } + + xTaskCreate(uart_rx_task_fn, rx_task_name, UART_TASK_STACK_SIZE, this, TASK_PRIORITY, &this->uart_rx_task_handle_); + if (this->uart_rx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create UART RX task"); + vTaskDelete(this->uart_tx_task_handle_); + this->uart_tx_task_handle_ = nullptr; + this->mark_failed(); + return; + } + + // Only register callbacks once both tasks exist, so a failed setup never drives + // DTR/RTS from a dead bridge. + this->usb_cdc_parent_->set_line_state_callback([this](bool dtr, bool rts) { this->set_line_state(dtr, rts); }); + this->usb_cdc_parent_->set_line_coding_callback([this](uint32_t, uint8_t, uint8_t, uint8_t) { + this->host_coding_seen_ = true; + // Another component owns the UART's framing while paused; resume() re-syncs. + if (this->paused_ == 0) { + this->set_line_coding(); + } + }); + + // Release the workers only now: until here a failed setup may still delete the TX + // task, which is safe only while it is parked and owns nothing in the driver. + xTaskNotifyGive(this->uart_tx_task_handle_); + xTaskNotifyGive(this->uart_rx_task_handle_); + + // loop() only services line-coding reloads; stay off the main loop until one is + // scheduled. + this->disable_loop(); +} + +void CDCACMUARTBridge::dump_config() { + ESP_LOGCONFIG(TAG, + "CDC-ACM UART Bridge:\n" + " UART Bus: %u\n" + " USB CDC Interface: %u", + this->uart_parent_->get_hw_serial_number(), this->usb_cdc_parent_->get_itf()); + LOG_PIN(" DTR Pin: ", this->dtr_pin_); + LOG_PIN(" RTS Pin: ", this->rts_pin_); +} + +void CDCACMUARTBridge::on_shutdown() { + // The UART (BUS) shuts down after this component (HARDWARE) and deletes its driver, + // freeing the ring buffer and mutexes the worker tasks block on. Suspending the + // tasks unlinks them from those objects first. + if (this->uart_rx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_rx_task_handle_); + } + if (this->uart_tx_task_handle_ != nullptr) { + vTaskSuspend(this->uart_tx_task_handle_); + } +} + +void CDCACMUARTBridge::loop() { + switch (this->state_) { + case MainState::MAIN_STATE_RELOAD_PENDING: + if ((App.get_loop_component_start_time() - this->reload_requested_at_) < UART_RELOAD_SETTLE_MS) { + return; + } + // Deliberately not gated on tx_idle_(): a host that re-codes the line mid-stream + // wants the new framing now, and its own in-flight bytes are its concern. + // apply_settings_live() rewrites the framing registers without reinstalling the + // driver, so the worker tasks blocked inside it are undisturbed. + this->uart_parent_->apply_settings_live(); + this->state_ = MainState::MAIN_STATE_RUNNING; + break; + case MainState::MAIN_STATE_PAUSING: + case MainState::MAIN_STATE_RESUMING: + // Let a host write that was in flight drain, FIFO included, before a reload + // flushes the FIFOs and truncates it. + if (!this->tx_idle_()) { + return; + } + if (this->state_ == MainState::MAIN_STATE_PAUSING) { + this->restore_configured_framing_(); + this->state_ = MainState::MAIN_STATE_PAUSED; + } else { + this->finish_resume_(); + } + break; + default: + break; + } + this->disable_loop(); +} + +void CDCACMUARTBridge::set_line_coding() { + if (!this->sync_host_framing_()) { + return; + } + // Coalesce rapid line-coding updates from the host. + this->reload_requested_at_ = App.get_loop_component_start_time(); + this->state_ = MainState::MAIN_STATE_RELOAD_PENDING; + // Main-loop context (via USBCDCACMInstance::process_events_). + this->enable_loop(); +} + +bool CDCACMUARTBridge::sync_host_framing_() { + // usb_cdc_acm has already translated the wire coding onto the CDC instance (main + // loop); mirror it here so the framing translation has a single source of truth. + bool changed = false; + + // Reject 0 (the CDC B0/hang-up encoding; older IDF revisions divide by the rate) + // and rates above the SoC ceiling. Anything in between is the driver's call, + // matching what a YAML-configured UART accepts. + const uint32_t baud = this->usb_cdc_parent_->get_baud_rate(); + if (baud == 0 || baud > SOC_UART_BITRATE_MAX) { + ESP_LOGW(TAG, "Ignoring unsupported baud rate %" PRIu32 " from host; keeping %" PRIu32, baud, + this->uart_parent_->get_baud_rate()); + } else if (this->uart_parent_->get_baud_rate() != baud) { + this->uart_parent_->set_baud_rate(baud); + changed = true; + } + + const uint8_t stop_bits = this->usb_cdc_parent_->get_stop_bits(); + if (this->uart_parent_->get_stop_bits() != stop_bits) { + this->uart_parent_->set_stop_bits(stop_bits); + changed = true; + } + + const auto parity = this->usb_cdc_parent_->get_parity(); + if (this->uart_parent_->get_parity() != parity) { + this->uart_parent_->set_parity(parity); + changed = true; + } + + // USB CDC permits data-bit counts the UART cannot represent (up to 16). + const uint8_t data_bits = this->usb_cdc_parent_->get_data_bits(); + if (data_bits < 5 || data_bits > 8) { + ESP_LOGW(TAG, "Ignoring unsupported data bits %u from host; keeping %u", data_bits, + this->uart_parent_->get_data_bits()); + } else if (this->uart_parent_->get_data_bits() != data_bits) { + this->uart_parent_->set_data_bits(data_bits); + changed = true; + } + + if (changed) { + ESP_LOGV(TAG, "Line coding: baud=%" PRIu32 ", data_bits=%u, stop_bits=%u, parity=%u", + this->uart_parent_->get_baud_rate(), this->uart_parent_->get_data_bits(), + this->uart_parent_->get_stop_bits(), static_cast(this->uart_parent_->get_parity())); + } + return changed; +} + +void CDCACMUARTBridge::pause() { + if (this->state_ == MainState::MAIN_STATE_PAUSING || this->state_ == MainState::MAIN_STATE_PAUSED) { + return; + } + this->paused_ = 1; + // A null RX task means setup() has not completed (or failed): nothing to stop, and + // the framing snapshot does not exist yet. Should setup() run later, the RX task + // starts parked. + if (this->uart_rx_task_handle_ == nullptr) { + this->state_ = MainState::MAIN_STATE_PAUSED; + return; + } + // Drops a coalesced host reload or a pending resume; loop() restores the framing + // once any host write in flight has drained. + this->state_ = MainState::MAIN_STATE_PAUSING; + this->enable_loop(); +} + +void CDCACMUARTBridge::resume() { + if (this->state_ != MainState::MAIN_STATE_PAUSING && this->state_ != MainState::MAIN_STATE_PAUSED) { + return; + } + if (this->uart_rx_task_handle_ == nullptr) { + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + return; + } + // A restore still waiting on the TX side is moot: the host's framing is kept. + if (!this->tx_idle_()) { + this->state_ = MainState::MAIN_STATE_RESUMING; + this->enable_loop(); + return; + } + this->finish_resume_(); + this->disable_loop(); +} + +void CDCACMUARTBridge::finish_resume_() { + // Take the bus back at a known framing before either task runs again: the host's + // if it ever sent one, else the YAML framing (the other owner may have changed it). + if (this->host_coding_seen_) { + this->sync_host_framing_(); + this->uart_parent_->apply_settings_live(); + } else { + this->restore_configured_framing_(); + } + this->paused_ = 0; + this->state_ = MainState::MAIN_STATE_RUNNING; + this->drive_line_state_(); + xTaskNotifyGive(this->uart_rx_task_handle_); +} + +bool CDCACMUARTBridge::tx_idle_() { + const auto uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + return this->tx_busy_ == 0 && uart_wait_tx_done(uart_num, 0) == ESP_OK; +} + +void CDCACMUARTBridge::restore_configured_framing_() { + // Always applied: the cached settings can lead the hardware by a pending reload, + // so they are no proof of what is live. + this->uart_parent_->set_baud_rate(this->configured_baud_rate_); + this->uart_parent_->set_parity(this->configured_parity_); + this->uart_parent_->set_stop_bits(this->configured_stop_bits_); + this->uart_parent_->set_data_bits(this->configured_data_bits_); + this->uart_parent_->apply_settings_live(); +} + +void CDCACMUARTBridge::set_line_state(bool dtr, bool rts) { + ESP_LOGV(TAG, "Line state: DTR=%d, RTS=%d", dtr, rts); + this->host_dtr_ = dtr; + this->host_rts_ = rts; + // Frozen while paused: a host opening the port must not reset a peer that another + // component is talking to. + if (this->paused_ == 0) { + this->drive_line_state_(); + } +} + +void CDCACMUARTBridge::drive_line_state_() { + if (this->dtr_pin_ != nullptr) { + this->dtr_pin_->digital_write(this->host_dtr_); + } + if (this->rts_pin_ != nullptr) { + this->rts_pin_->digital_write(this->host_rts_); + } +} + +void CDCACMUARTBridge::uart_rx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_rx_task_(); +} + +void CDCACMUARTBridge::uart_tx_task_fn(void *arg) { + auto *bridge = static_cast(arg); + bridge->uart_tx_task_(); +} + +void CDCACMUARTBridge::uart_rx_task_() { + TaskHandle_t usb_tx_handle = this->usb_cdc_parent_->get_tx_task_handle(); + RingbufHandle_t usb_tx_ringbuf = this->usb_cdc_parent_->get_tx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t tx_full_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + + uint8_t *data = this->uart_rx_buffer_.data(); + const size_t buf_size = this->uart_rx_buffer_.size(); + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + if (this->paused_ != 0) { + // Parked until resume() notifies; nothing is read, so the other owner sees + // every byte. + this->rx_parked_ = 1; + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + this->rx_parked_ = 0; + continue; + } + + // Block until at least one byte is available from UART. + int total_rx_size = uart_read_bytes(uart_num, data, 1, pdMS_TO_TICKS(UART_RX_WAIT_MS)); + if (total_rx_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", total_rx_size); + } + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + if (total_rx_size == 0) { + continue; + } + // pause() landed during the read: don't forward a byte to a host that is gone. + if (this->paused_ != 0) { + continue; + } + + // Drain the currently buffered burst without waiting. + while (true) { + int rx_data_size = uart_read_bytes(uart_num, data + total_rx_size, buf_size - total_rx_size, 0); + if (rx_data_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART read failed: %d", rx_data_size); + } + break; + } + if (rx_data_size == 0) { + break; + } + ESP_LOGV(TAG, "UART RX: %d bytes", rx_data_size); + total_rx_size += rx_data_size; + if (total_rx_size >= (int) buf_size) { + break; + } + } + + ringbuf_send_with_retry(usb_tx_ringbuf, data, total_rx_size, &tx_full_log_ms); + + ESP_LOGV(TAG, "UART RX: waking up USB TX task"); + xTaskNotifyGive(usb_tx_handle); + } +} + +void CDCACMUARTBridge::uart_tx_task_() { + RingbufHandle_t usb_rx_ringbuf = this->usb_cdc_parent_->get_rx_ringbuf(); + uart_port_t uart_num = static_cast(this->uart_parent_->get_hw_serial_number()); + uint8_t *data_to_uart = this->uart_tx_buffer_.data(); + const size_t buf_size = this->uart_tx_buffer_.size(); + size_t rx_size; + // Back-dated so a problem within the first LOG_THROTTLE_MS of uptime still logs. + uint32_t err_log_ms = millis() - LOG_THROTTLE_MS; + uint32_t drop_log_ms = millis() - LOG_THROTTLE_MS; + + // Released by setup() once both tasks exist. + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + while (true) { + ESP_LOGV(TAG, "Waiting for data to send to UART"); + esp_err_t ret = usb_cdc_acm::ringbuf_read_bytes(usb_rx_ringbuf, data_to_uart, buf_size, &rx_size, portMAX_DELAY); + + if (ret != ESP_OK) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "USB RX RingBuf read failed"); + } + // Yield: this task runs above the main loop, so a persistent failure must not + // become a tight loop. + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + + // Another component owns the UART; host bytes must not interleave with its traffic. + // tx_busy_ goes up before the check so is_paused() cannot miss a write in flight. + this->tx_busy_ = 1; + if (this->paused_ != 0) { + this->tx_busy_ = 0; + if (should_log_now(&drop_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGW(TAG, "Paused; dropping %zu bytes from host", rx_size); + } + continue; + } + + ESP_LOGV(TAG, "Sending %zu bytes to UART", rx_size); + // Signed: uart_write_bytes() returns -1 on error. + int xfer_size = uart_write_bytes(uart_num, data_to_uart, rx_size); + this->tx_busy_ = 0; + + if (xfer_size < 0) { + if (should_log_now(&err_log_ms, LOG_THROTTLE_MS)) { + ESP_LOGE(TAG, "UART write failed: %d", xfer_size); + } + } else if (static_cast(xfer_size) != rx_size) { + ESP_LOGW(TAG, "UART write incomplete (%d/%zu bytes)", xfer_size, rx_size); + } + } +} + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h new file mode 100644 index 0000000000..405b794653 --- /dev/null +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h @@ -0,0 +1,118 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/usb_cdc_acm/usb_cdc_acm.h" +#include "esphome/core/component.h" + +#include +#include +#include "sdkconfig.h" + +namespace esphome::cdc_acm_uart { + +class CDCACMUARTBridge final : public Component { + public: + // Upper bound on the RX task's blocking read, so pause() takes effect without + // aborting the read. Arriving bytes still unblock it immediately. + static constexpr uint32_t UART_RX_WAIT_MS = 250; + + CDCACMUARTBridge(uart::IDFUARTComponent *uart_parent, usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent) + : uart_parent_(uart_parent), usb_cdc_parent_(usb_cdc_parent) {} + + void setup() override; + void loop() override; + void dump_config() override; + void on_shutdown() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + void set_dtr_pin(GPIOPin *dtr_pin) { this->dtr_pin_ = dtr_pin; } + void set_rts_pin(GPIOPin *rts_pin) { this->rts_pin_ = rts_pin; } + + void set_line_coding(); + void set_line_state(bool dtr, bool rts); + uart::IDFUARTComponent *get_uart_parent() const { return this->uart_parent_; } + + /** + * Stop forwarding in both directions and hand the UART back to its configured + * framing, so another component may use the bus. Main-loop only. The RX task parks + * within UART_RX_WAIT_MS (a byte it was already reading is discarded). A host write + * already in flight is allowed to drain first, which at low baud rates can take + * seconds; the framing is restored only after that, so poll is_paused() rather than + * waiting a fixed interval. Host bytes not yet written to the UART are discarded. + * The DTR/RTS outputs hold their state while paused and follow the host again on + * resume(). + */ + void pause(); + /** + * Re-apply the host's line coding and line state, then resume forwarding. Main-loop + * only. Deferred until any host write still draining has finished, so the reload + * never truncates it. + */ + void resume(); + /// True once both worker tasks are off the bus and the configured framing is restored. + /// With no RX task (setup() failed or has not run) there is nothing to wait for. + bool is_paused() const { + return this->state_ == MainState::MAIN_STATE_PAUSED && + (this->uart_rx_task_handle_ == nullptr || this->rx_parked_ != 0); + } + + protected: + static void uart_rx_task_fn(void *arg); + static void uart_tx_task_fn(void *arg); + void uart_rx_task_(); + void uart_tx_task_(); + void restore_configured_framing_(); + // True when the TX task has no write in flight and the UART TX FIFO has drained. + bool tx_idle_(); + void finish_resume_(); + void drive_line_state_(); + // Copy the host's line coding onto the UART settings; true if anything changed. + bool sync_host_framing_(); + + TaskHandle_t uart_rx_task_handle_{nullptr}; + TaskHandle_t uart_tx_task_handle_{nullptr}; + + GPIOPin *dtr_pin_{nullptr}; + GPIOPin *rts_pin_{nullptr}; + + uint32_t reload_requested_at_{0}; + + // Worker staging, each sized to the CDC ring buffer it feeds or drains. + std::array uart_rx_buffer_{}; + std::array uart_tx_buffer_{}; + + uart::IDFUARTComponent *uart_parent_; + usb_cdc_acm::USBCDCACMInstance *usb_cdc_parent_; + + // YAML framing, captured at setup; the host's line coding overwrites the UART's + // settings, so pause() needs the original to restore. + uint32_t configured_baud_rate_{0}; + uart::UARTParityOptions configured_parity_{uart::UART_CONFIG_PARITY_NONE}; + uint8_t configured_stop_bits_{0}; + uint8_t configured_data_bits_{0}; + + // Written on the main loop, read by both worker tasks. uint8_t rather than bool: + // GCC on Xtensa emits an out-of-line call for atomic. + std::atomic paused_{0}; + // Raised by the RX task while parked and by the TX task around each UART write, so + // the pause hand-off knows when the bus is actually free. + std::atomic rx_parked_{0}; + std::atomic tx_busy_{0}; + // Main-loop state; paused_ mirrors it for the worker tasks. + enum class MainState : uint8_t { + MAIN_STATE_RUNNING, + MAIN_STATE_RELOAD_PENDING, // host line coding debounced, forwarding continues + MAIN_STATE_PAUSING, // waiting for TX idle to restore the configured framing + MAIN_STATE_PAUSED, + MAIN_STATE_RESUMING, // resume() requested while a host write still drains + }; + MainState state_{MainState::MAIN_STATE_RUNNING}; + // Host line state, recorded even while paused so resume() can re-drive the pins. + bool host_dtr_{false}; + bool host_rts_{false}; + // True once the host has sent any line coding; resume() then re-syncs to it. + bool host_coding_seen_{false}; +}; + +} // namespace esphome::cdc_acm_uart +#endif diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 3fbca1a6d0..e64092d30e 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 a8d6d778ae..d6aba02f92 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 f80de151b1..d08952115b 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 0667bd91a2..f5a31b31c4 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 a8edaae6ea..47143e6e60 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 6c49b31030..060564b4ba 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 844bfdfa88..e381db7b5c 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 a36f0b0059..c3939735a4 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 ccc5a03964..34e9b99ce8 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 49a625e3f1..6e98162baa 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" @@ -30,6 +35,7 @@ CONF_KEYS = "keys" CONF_LABEL = "label" CONF_LIBRETINY = "libretiny" CONF_LOOP = "loop" +CONF_MANUFACTURER = "manufacturer" CONF_NOX_INDEX = "nox_index" CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" @@ -37,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" @@ -60,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 0000000000..a20dc57729 --- /dev/null +++ b/esphome/components/const/css_colors.py @@ -0,0 +1,152 @@ +"""Standard CSS named colors, as 0xRRGGBB values.""" + +CSS_COLORS: dict[str, int] = { + "aliceblue": 0xF0F8FF, + "antiquewhite": 0xFAEBD7, + "aqua": 0x00FFFF, + "aquamarine": 0x7FFFD4, + "azure": 0xF0FFFF, + "beige": 0xF5F5DC, + "bisque": 0xFFE4C4, + "black": 0x000000, + "blanchedalmond": 0xFFEBCD, + "blue": 0x0000FF, + "blueviolet": 0x8A2BE2, + "brown": 0xA52A2A, + "burlywood": 0xDEB887, + "cadetblue": 0x5F9EA0, + "chartreuse": 0x7FFF00, + "chocolate": 0xD2691E, + "coral": 0xFF7F50, + "cornflowerblue": 0x6495ED, + "cornsilk": 0xFFF8DC, + "crimson": 0xDC143C, + "cyan": 0x00FFFF, + "darkblue": 0x00008B, + "darkcyan": 0x008B8B, + "darkgoldenrod": 0xB8860B, + "darkgray": 0xA9A9A9, + "darkgreen": 0x006400, + "darkgrey": 0xA9A9A9, + "darkkhaki": 0xBDB76B, + "darkmagenta": 0x8B008B, + "darkolivegreen": 0x556B2F, + "darkorange": 0xFF8C00, + "darkorchid": 0x9932CC, + "darkred": 0x8B0000, + "darksalmon": 0xE9967A, + "darkseagreen": 0x8FBC8F, + "darkslateblue": 0x483D8B, + "darkslategray": 0x2F4F4F, + "darkslategrey": 0x2F4F4F, + "darkturquoise": 0x00CED1, + "darkviolet": 0x9400D3, + "deeppink": 0xFF1493, + "deepskyblue": 0x00BFFF, + "dimgray": 0x696969, + "dimgrey": 0x696969, + "dodgerblue": 0x1E90FF, + "firebrick": 0xB22222, + "floralwhite": 0xFFFAF0, + "forestgreen": 0x228B22, + "fuchsia": 0xFF00FF, + "gainsboro": 0xDCDCDC, + "ghostwhite": 0xF8F8FF, + "goldenrod": 0xDAA520, + "gold": 0xFFD700, + "gray": 0x808080, + "green": 0x008000, + "greenyellow": 0xADFF2F, + "grey": 0x808080, + "honeydew": 0xF0FFF0, + "hotpink": 0xFF69B4, + "indianred": 0xCD5C5C, + "indigo": 0x4B0082, + "ivory": 0xFFFFF0, + "khaki": 0xF0E68C, + "lavenderblush": 0xFFF0F5, + "lavender": 0xE6E6FA, + "lawngreen": 0x7CFC00, + "lemonchiffon": 0xFFFACD, + "lightblue": 0xADD8E6, + "lightcoral": 0xF08080, + "lightcyan": 0xE0FFFF, + "lightgoldenrodyellow": 0xFAFAD2, + "lightgray": 0xD3D3D3, + "lightgreen": 0x90EE90, + "lightgrey": 0xD3D3D3, + "lightpink": 0xFFB6C1, + "lightsalmon": 0xFFA07A, + "lightseagreen": 0x20B2AA, + "lightskyblue": 0x87CEFA, + "lightslategray": 0x778899, + "lightslategrey": 0x778899, + "lightsteelblue": 0xB0C4DE, + "lightyellow": 0xFFFFE0, + "lime": 0x00FF00, + "limegreen": 0x32CD32, + "linen": 0xFAF0E6, + "magenta": 0xFF00FF, + "maroon": 0x800000, + "mediumaquamarine": 0x66CDAA, + "mediumblue": 0x0000CD, + "mediumorchid": 0xBA55D3, + "mediumpurple": 0x9370DB, + "mediumseagreen": 0x3CB371, + "mediumslateblue": 0x7B68EE, + "mediumspringgreen": 0x00FA9A, + "mediumturquoise": 0x48D1CC, + "mediumvioletred": 0xC71585, + "midnightblue": 0x191970, + "mintcream": 0xF5FFFA, + "mistyrose": 0xFFE4E1, + "moccasin": 0xFFE4B5, + "navajowhite": 0xFFDEAD, + "navy": 0x000080, + "oldlace": 0xFDF5E6, + "olive": 0x808000, + "olivedrab": 0x6B8E23, + "orange": 0xFFA500, + "orangered": 0xFF4500, + "orchid": 0xDA70D6, + "palegoldenrod": 0xEEE8AA, + "palegreen": 0x98FB98, + "paleturquoise": 0xAFEEEE, + "palevioletred": 0xDB7093, + "papayawhip": 0xFFEFD5, + "peachpuff": 0xFFDAB9, + "peru": 0xCD853F, + "pink": 0xFFC0CB, + "plum": 0xDDA0DD, + "powderblue": 0xB0E0E6, + "purple": 0x800080, + "rebeccapurple": 0x663399, + "red": 0xFF0000, + "rosybrown": 0xBC8F8F, + "royalblue": 0x4169E1, + "saddlebrown": 0x8B4513, + "salmon": 0xFA8072, + "sandybrown": 0xF4A460, + "seagreen": 0x2E8B57, + "seashell": 0xFFF5EE, + "sienna": 0xA0522D, + "silver": 0xC0C0C0, + "skyblue": 0x87CEEB, + "slateblue": 0x6A5ACD, + "slategray": 0x708090, + "slategrey": 0x708090, + "snow": 0xFFFAFA, + "springgreen": 0x00FF7F, + "steelblue": 0x4682B4, + "tan": 0xD2B48C, + "teal": 0x008080, + "thistle": 0xD8BFD8, + "tomato": 0xFF6347, + "turquoise": 0x40E0D0, + "violet": 0xEE82EE, + "wheat": 0xF5DEB3, + "white": 0xFFFFFF, + "whitesmoke": 0xF5F5F5, + "yellow": 0xFFFF00, + "yellowgreen": 0x9ACD32, +} diff --git a/esphome/components/coolix/climate.py b/esphome/components/coolix/climate.py index 3eb8dbe2f4..fcca8b89db 100644 --- a/esphome/components/coolix/climate.py +++ b/esphome/components/coolix/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] @@ -12,4 +12,5 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(CoolixClimate) async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("coolix") # used from C++ await climate_ir.new_climate_ir(config) diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 91b76f11c0..555f0030a5 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -13,12 +13,6 @@ void CopySwitch::setup() { void CopySwitch::dump_config() { LOG_SWITCH("", "Copy Switch", this); } -void CopySwitch::write_state(bool state) { - if (state) { - source_->turn_on(); - } else { - source_->turn_off(); - } -} +void CopySwitch::write_state(bool state) { this->source_->control(state); } } // namespace esphome::copy diff --git a/esphome/components/counter/__init__.py b/esphome/components/counter/__init__.py new file mode 100644 index 0000000000..c58ce8a01e --- /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 0000000000..22adb49cca --- /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 0000000000..3468c4d5a0 --- /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 0000000000..0f36b1721b --- /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 011b2c2f04..0f5aa9b450 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 0a5a447ab9..2032eba0ec 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 87ea858c70..80a63d808f 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 5f14457101..d087c99bf1 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 9bed0f3f59..86b6965e3a 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 f8b6446006..3a33e5e004 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 93ce1411f8..9404d21512 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 fec620b5ba..57d305cb52 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 736e26f4a7..fd31318ce5 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 a889d13329..898d7d6c0e 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 e53cb0d1e4..07c67a2b93 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -23,11 +23,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_DEBUG_ID, - FILTER_SOURCE_FILES, - DebugComponent, -) +from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["debug"] @@ -110,32 +106,13 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - if free_conf := config.get(CONF_FREE): - sens = await sensor.new_sensor(free_conf) - cg.add(debug_component.set_free_sensor(sens)) - - if block_conf := config.get(CONF_BLOCK): - sens = await sensor.new_sensor(block_conf) - cg.add(debug_component.set_block_sensor(sens)) - - if fragmentation_conf := config.get(CONF_FRAGMENTATION): - sens = await sensor.new_sensor(fragmentation_conf) - cg.add(debug_component.set_fragmentation_sensor(sens)) - - if min_free_conf := config.get(CONF_MIN_FREE): - sens = await sensor.new_sensor(min_free_conf) - cg.add(debug_component.set_min_free_sensor(sens)) - - if loop_time_conf := config.get(CONF_LOOP_TIME): - sens = await sensor.new_sensor(loop_time_conf) - cg.add(debug_component.set_loop_time_sensor(sens)) - - if psram_conf := config.get(CONF_PSRAM): - sens = await sensor.new_sensor(psram_conf) - cg.add(debug_component.set_psram_sensor(sens)) - - if cpu_freq_conf := config.get(CONF_CPU_FREQUENCY): - sens = await sensor.new_sensor(cpu_freq_conf) - cg.add(debug_component.set_cpu_frequency_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREE, hub.set_free_sensor) + await sensors(CONF_BLOCK, hub.set_block_sensor) + await sensors(CONF_FRAGMENTATION, hub.set_fragmentation_sensor) + await sensors(CONF_MIN_FREE, hub.set_min_free_sensor) + await sensors(CONF_LOOP_TIME, hub.set_loop_time_sensor) + await sensors(CONF_PSRAM, hub.set_psram_sensor) + await sensors(CONF_CPU_FREQUENCY, hub.set_cpu_frequency_sensor) diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index 9d4fcc1b42..43ed0d15c6 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -9,11 +9,7 @@ from esphome.const import ( ) from esphome.types import ConfigType -from . import ( # noqa: F401 pylint: disable=unused-import - CONF_DEBUG_ID, - FILTER_SOURCE_FILES, - DebugComponent, -) +from . import CONF_DEBUG_ID, FILTER_SOURCE_FILES, DebugComponent # noqa: F401 pylint: disable=unused-import DEPENDENCIES = ["debug"] @@ -35,11 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - if CONF_DEVICE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DEVICE]) - cg.add(debug_component.set_device_info_sensor(sens)) - if CONF_RESET_REASON in config: - sens = await text_sensor.new_text_sensor(config[CONF_RESET_REASON]) - cg.add(debug_component.set_reset_reason_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DEVICE, hub.set_device_info_sensor) + await text_sensors(CONF_RESET_REASON, hub.set_reset_reason_sensor) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index dc03708645..06286933b9 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -214,16 +214,6 @@ def _validate_sleep_duration(value: core.TimePeriod) -> core.TimePeriod: deep_sleep_ns = cg.esphome_ns.namespace("deep_sleep") DeepSleepComponent = deep_sleep_ns.class_("DeepSleepComponent", cg.Component) EnterDeepSleepAction = deep_sleep_ns.class_("EnterDeepSleepAction", automation.Action) -PreventDeepSleepAction = deep_sleep_ns.class_( - "PreventDeepSleepAction", - automation.Action, - cg.Parented.template(DeepSleepComponent), -) -AllowDeepSleepAction = deep_sleep_ns.class_( - "AllowDeepSleepAction", - automation.Action, - cg.Parented.template(DeepSleepComponent), -) WakeupPinMode = deep_sleep_ns.enum("WakeupPinMode") WAKEUP_PIN_MODES = { @@ -422,8 +412,12 @@ async def to_code(config: ConfigType) -> None: if CONF_TOUCH_WAKEUP in config: cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP])) - if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations: - zephyr_add_prj_conf("POWEROFF", True) + if CORE.using_zephyr: + # Devices are suspended when CPU is entering a low power state + # https://github.com/nrfconnect/sdk-zephyr/blob/v3.7.99-ncs2-2/doc/services/pm/device.rst#system-managed-device-power-management + zephyr_add_prj_conf("PM_DEVICE", True) + if "zigbee" not in CORE.loaded_integrations: + zephyr_add_prj_conf("POWEROFF", True) cg.add_define("USE_DEEP_SLEEP") @@ -486,27 +480,15 @@ async def deep_sleep_enter_to_code( return var -@automation.register_action( - "deep_sleep.prevent", - PreventDeepSleepAction, - automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), - synchronous=True, -) -@automation.register_action( - "deep_sleep.allow", - AllowDeepSleepAction, - automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), - synchronous=True, -) -async def deep_sleep_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("deep_sleep.prevent", "prevent_deep_sleep()"), + ("deep_sleep.allow", "allow_deep_sleep()"), +): + automation.register_apply_action( + _name, + automation.maybe_simple_id(DEEP_SLEEP_ACTION_SCHEMA), + automation.ApplyCall(_call), + ) FILTER_SOURCE_FILES = filter_source_files_from_platform( diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index 2c97dc3211..a955095875 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -44,7 +44,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep..."); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } } diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index 9a3e537e05..d33102bf4f 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -17,6 +17,7 @@ void DeepSleepComponent::setup() { void DeepSleepComponent::schedule_sleep_() { this->next_enter_deep_sleep_ = false; + this->disable_loop(); const optional run_duration = get_run_duration_(); if (run_duration.has_value()) { ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration); @@ -45,7 +46,7 @@ void DeepSleepComponent::loop() { void DeepSleepComponent::begin_sleep(bool manual) { if (this->prevent_ && !manual) { - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return; } diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 208f88d707..2afe19c069 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -121,8 +121,6 @@ class Ext1WakeTrigger : public Trigger<>, public Component { template class EnterDeepSleepAction; -template class PreventDeepSleepAction; - /** This component allows setting up the node to go into deep sleep mode to conserve battery. * * To set this component up, first set *when* the deep sleep should trigger using set_run_cycles @@ -190,6 +188,11 @@ class DeepSleepComponent final : public Component { void schedule_sleep_(); bool should_teardown_(); + void defer_sleep_() { + this->next_enter_deep_sleep_ = true; + this->enable_loop(); + } + #ifdef USE_BK72XX bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const; bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); } @@ -293,15 +296,4 @@ template class EnterDeepSleepAction final : public Action #endif }; -template -class PreventDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } -}; - -template class AllowDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } -}; - } // namespace esphome::deep_sleep diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 3fa1a1f1ed..20297028b2 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -100,7 +100,7 @@ bool DeepSleepComponent::prepare_to_sleep_() { this->status_set_warning(); ESP_LOGW(TAG, "Waiting for wakeup pin state change"); } - this->next_enter_deep_sleep_ = true; + this->defer_sleep_(); return false; } return true; diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index bb18e6ba8c..296afcd318 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 f81d1cd1b6..1195f595c8 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 1db6b394c5..957583e4ff 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 51562f923c..6334e10ac5 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 a5f4c99014..c363208531 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 448a18a477..500f20d8da 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 7376adb287..c7ae70b834 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 2bc6e94515..88877b2dc2 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 7a66da11f2..1cd75ddaf0 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -39,15 +39,6 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayPageShowNextAction = display_ns.class_( - "DisplayPageShowNextAction", automation.Action -) -DisplayPageShowPrevAction = display_ns.class_( - "DisplayPageShowPrevAction", automation.Action -) -DisplayIsDisplayingPageCondition = display_ns.class_( - "DisplayIsDisplayingPageCondition", automation.Condition -) DisplayOnPageChangeTrigger = display_ns.class_( "DisplayOnPageChangeTrigger", automation.Trigger ) @@ -288,39 +279,27 @@ async def display_page_show_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +# The id is the display itself, so it cannot be a lambda; a plain use_id rejects one clearly. +DISPLAY_PAGE_CYCLE_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(CONF_ID): cv.use_id(Display), + } +) + +automation.register_apply_action( "display.page.show_next", - DisplayPageShowNextAction, - maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), - } - ), - synchronous=True, + DISPLAY_PAGE_CYCLE_ACTION_SCHEMA, + automation.ApplyCall("show_next_page()"), ) -async def display_page_show_next_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( +automation.register_apply_action( "display.page.show_previous", - DisplayPageShowPrevAction, - maybe_simple_id( - { - cv.GenerateID(CONF_ID): cv.templatable(cv.use_id(Display)), - } - ), - synchronous=True, + DISPLAY_PAGE_CYCLE_ACTION_SCHEMA, + automation.ApplyCall("show_prev_page()"), ) -async def display_page_show_previous_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) -@automation.register_condition( +automation.register_apply_condition( "display.is_displaying_page", - DisplayIsDisplayingPageCondition, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Display), @@ -328,13 +307,8 @@ async def display_page_show_previous_to_code(config, action_id, template_arg, ar }, key=CONF_PAGE_ID, ), + automation.ApplyCall("get_active_page() == {}", ((CONF_PAGE_ID, DisplayPagePtr),)), ) -async def display_is_displaying_page_to_code(config, condition_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - page = await cg.get_variable(config[CONF_PAGE_ID]) - var = cg.new_Pvariable(condition_id, template_arg, paren) - cg.add(var.set_page(page)) - return var @coroutine_with_priority(CoroPriority.CORE) diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index c2d45dbb60..66fadf12ec 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -3,6 +3,7 @@ #include #include #include "display_color_utils.h" +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -770,10 +771,12 @@ Rect Display::get_clipping() const { void Display::clear_clipping_() { this->clipping_rectangle_.clear(); } +void Display::feed_wdt_pixel_slow_() { App.feed_wdt(); } + bool Display::clip(int x, int y) { if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height()) return false; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; return true; } diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index a9ffda422d..cc75eb70b8 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. @@ -758,6 +760,13 @@ class Display : public PollingComponent { bool is_clipping() const { return !this->clipping_rectangle_.empty(); } + /// Whether (x, y) falls outside the active clipping rectangle. Tests the + /// stack top in place: get_clipping() is out of line and returns the Rect + /// by value, which per pixel drawing cannot afford. + bool ESPHOME_ALWAYS_INLINE is_point_clipped(int x, int y) const { + return this->is_clipping() && !this->clipping_rectangle_.back().inside(x, y); + } + /** Check if pixel is within region of display. */ bool clip(int x, int y); @@ -774,6 +783,17 @@ class Display : public PollingComponent { void do_update_(); void clear_clipping_(); + /// Watchdog feed for per pixel loops. App.feed_wdt() is already rate + /// limited, but every call reads the clock; only every 256th pixel makes + /// that call, so the real feeds are unchanged and a pixel costs a counter. + /// At 20 us per pixel on the slowest e-paper path that is about 5 ms + /// between clock reads. + void ESPHOME_ALWAYS_INLINE feed_wdt_per_pixel_() { + if (++this->wdt_pixel_counter_ == 0) + this->feed_wdt_pixel_slow_(); + } + void feed_wdt_pixel_slow_(); + virtual int get_height_internal() = 0; virtual int get_width_internal() = 0; @@ -793,6 +813,7 @@ class Display : public PollingComponent { std::vector on_page_change_triggers_; bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; + uint8_t wdt_pixel_counter_{0}; bool show_test_card_{false}; }; @@ -829,36 +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/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index 4c91914049..d564ea67bd 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -2,7 +2,6 @@ #include -#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::display { @@ -44,7 +43,7 @@ int DisplayBuffer::get_height() { } void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -64,7 +63,7 @@ void HOT DisplayBuffer::draw_pixel_at(int x, int y, Color color) { break; } this->draw_absolute_pixel_internal(x, y, color); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } } // namespace esphome::display diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index a47f726917..3ecf6d1cf1 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -63,16 +63,6 @@ bool Rect::equal(Rect rect) const { return (rect.x == this->x) && (rect.w == this->w) && (rect.y == this->y) && (rect.h == this->h); } -bool Rect::inside(int16_t test_x, int16_t test_y, bool absolute) const { // NOLINT - if (!this->is_set()) { - return true; - } - if (absolute) { - return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); - } - return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; -} - bool Rect::inside(Rect rect) const { if (!this->is_set() || !rect.is_set()) { return true; diff --git a/esphome/components/display/rect.h b/esphome/components/display/rect.h index f4958fab88..d65d844b9e 100644 --- a/esphome/components/display/rect.h +++ b/esphome/components/display/rect.h @@ -26,7 +26,15 @@ class Rect { void shrink(Rect rect); bool inside(Rect rect) const; - bool inside(int16_t test_x, int16_t test_y, bool absolute = true) const; + bool ESPHOME_ALWAYS_INLINE inside(int16_t test_x, int16_t test_y, bool absolute = true) const { + if (!this->is_set()) { + return true; + } + if (absolute) { + return test_x >= this->x && test_x < this->x2() && test_y >= this->y && test_y < this->y2(); + } + return test_x >= 0 && test_x < this->w && test_y >= 0 && test_y < this->h; + } bool equal(Rect rect) const; void info(const std::string &prefix = "rect info:"); }; diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 2120abe5f7..bb493ae8d3 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 be0044ffa4..477a971d47 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 bdbf798df5..e672fb200f 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 8b8fd8373b..e3827b484c 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 238fc7b21a..ec544ef3a4 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 a3ae3eb5af..7a15283f3b 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 a9e91de0b1..9b41ac7e0f 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 6d878a80a5..6b035d5c98 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 fdad47fb05..a7110bd6d2 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 639847345f..6cd122fb96 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 cc8901cf38..8c6d469fb8 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 3821f3e10e..7eae375cd6 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 7a1b084fe0..8efc2de753 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 6db197a78c..c56005e7ad 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 56a7fb8b55..eadba1ea3f 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 1bdfb0c0a6..6da0d10af9 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 bfd758f92f..cb49986f7c 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 7b7c48c0b0..0240aa5ccc 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 e9da924de5..487e8e5ef4 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 3214f932bf..1fab038c28 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -196,6 +196,15 @@ void EPaperBase::process_state_() { break; case EPaperState::UPDATE: this->do_update_(); // Calls ESPHome (current page) lambda + if (this->full_update_requested_) { + // Refresh the whole panel even if nothing was drawn + this->full_update_requested_ = false; + this->update_count_ = 0; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) { this->set_state_(EPaperState::IDLE); return; @@ -299,7 +308,7 @@ bool EPaperBase::initialise(bool partial) { * @return false if the coordinates are out of bounds */ bool EPaperBase::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; if (this->effective_transform_ & SWAP_XY) std::swap(x, y); @@ -327,9 +336,9 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { return; const size_t byte_position = y * this->row_width_ + x / 8; const uint8_t bit_position = x % 8; - const uint8_t pixel_bit = 0x80 >> bit_position; + const uint8_t pixel_bit = 0x80u >> bit_position; const auto original = this->buffer_[byte_position]; - if ((color_to_bit(color) == 0)) { + if (color_to_mono(color) == 0) { this->buffer_[byte_position] = original & ~pixel_bit; } else { this->buffer_[byte_position] = original | pixel_bit; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 8e2fd78e62..96755a404e 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 f44b59e803..d0740595fc 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 0000000000..e845a5d585 --- /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 0000000000..ddf3d4e089 --- /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 0000000000..18fbb016f3 --- /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 0000000000..47436d2db1 --- /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 34e65061f3..fbedd6371d 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 a0457c5812..ca824dccd4 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 13f1035045..2249529611 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 983f5bb382..eb51ba1f2a 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 bea133c328..91c649808a 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 74a288977d..aecda72364 100644 --- a/esphome/components/epaper_spi/models/waveshare.py +++ b/esphome/components/epaper_spi/models/waveshare.py @@ -6,8 +6,12 @@ from . import EpaperModel class WaveshareModel(EpaperModel): - def __init__(self, name, lut, lut_partial=None, **defaults): - super().__init__(name, "EpaperWaveshare", **defaults) + def __init__( + self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults + ): + # A partial LUT is what lets EpaperWaveshare do partial refresh + defaults.setdefault("partial_update", lut_partial is not None) + super().__init__(name, class_name=class_name, **defaults) self.lut = lut self.lut_partial = lut_partial diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 892b67b270..5afc22aec4 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -153,13 +153,14 @@ bool ES7210::configure_mic_gain_() { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv)); // Configure mic 3 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + // MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains. + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv)); // Configure mic 4 - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv)); diff --git a/esphome/components/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index 0386d84200..9a4da3744a 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 b81bcd13cf..62ddd6cb38 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 e9a041b0b1..df72c590a3 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,8 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_NVS_CACHE_IN_PSRAM = "nvs_cache_in_psram" +CONF_FLASH_CHIP = "flash_chip" CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" CONF_NVS_ENCRYPTION = "nvs_encryption" @@ -189,6 +195,13 @@ PSRAM_XIP_VARIANTS = { VARIANT_ESP32S31, } +# Variants whose ROM exports a full-format vsnprintf but no vasprintf +# (esp32c6.rom.newlib-normal.ld). There, the newlib printf engine is only +# linked because esp_http_client calls vasprintf; see vasprintf_stubs.cpp. +# The other variants either export both (classic ESP32, nano-format only) or +# neither, so the engine is already in the image and the wrap saves nothing. +ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS = {VARIANT_ESP32C6} + # NVS encryption (HMAC peripheral scheme) is only available on variants that # expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original # ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral @@ -225,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 @@ -310,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. @@ -416,6 +457,7 @@ ARDUINO_DISABLED_LIBRARIES: frozenset[str] = frozenset( "Hash", "HTTPClient", "HTTPUpdate", + "HTTPUpdateServer", "Insights", "LittleFS", "Matter", @@ -457,6 +499,20 @@ ESP32_CHIP_REVISIONS = { "3.1": "CONFIG_ESP32_REV_MIN_3_1", } +# Flash vendor drivers ESP-IDF can link; each costs IRAM plus a 124 B table in DRAM +# and only the one matching the flash ID is ever used +ESP32_FLASH_CHIPS = { + "gd": "CONFIG_SPI_FLASH_SUPPORT_GD_CHIP", + "issi": "CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP", + "mxic": "CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP", + "winbond": "CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP", + "boya": "CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP", + "th": "CONFIG_SPI_FLASH_SUPPORT_TH_CHIP", + "mxic_opi": "CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP", +} +FLASH_CHIP_GENERIC = "generic" +FLASH_CHIP_OPI = "mxic_opi" # the octal driver, ESP32-S3 only + # Socket limit configuration for ESP-IDF # ESP-IDF CONFIG_LWIP_MAX_SOCKETS has range 1-253, default 10 DEFAULT_MAX_SOCKETS = 10 # ESP-IDF default @@ -615,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"): @@ -678,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) @@ -747,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, @@ -909,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), @@ -941,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), @@ -976,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), @@ -997,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", } @@ -1512,6 +1671,13 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_MINIMUM_CHIP_REVISION], ) ) + if config[CONF_VARIANT] != VARIANT_ESP32S3 and config.get(CONF_FLASH_MODE) == "opi": + errs.append( + cv.Invalid( + f"'{CONF_FLASH_MODE}: opi' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FLASH_MODE], + ) + ) if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_SRAM1_AS_IRAM]: errs.append( cv.Invalid( @@ -1519,6 +1685,25 @@ def final_validate(config) -> None: path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_SRAM1_AS_IRAM], ) ) + if (flash_chip := advanced.get(CONF_FLASH_CHIP)) is not None: + opi = flash_chip == FLASH_CHIP_OPI + if opi and config[CONF_VARIANT] != VARIANT_ESP32S3: + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' is only supported on {VARIANT_ESP32S3}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) + elif opi != (config.get(CONF_FLASH_MODE) == "opi"): + errs.append( + cv.Invalid( + f"'{CONF_FLASH_CHIP}: {flash_chip}' requires '{CONF_FLASH_MODE}: opi'" + if opi + else f"'{CONF_FLASH_CHIP}: {flash_chip}' does not match " + f"'{CONF_FLASH_MODE}: opi'; octal flash uses {FLASH_CHIP_OPI}", + path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_FLASH_CHIP], + ) + ) if ( config[CONF_VARIANT] != VARIANT_ESP32P4 and config.get(CONF_ENGINEERING_SAMPLE) is not None @@ -1544,6 +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) @@ -1731,7 +1939,10 @@ 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" CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram" CONF_DISABLE_FATFS = "disable_fatfs" CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram" @@ -1744,10 +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_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" @@ -1786,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 @@ -1811,33 +2022,57 @@ 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. + """Keep mbedTLS PKCS#7 support (CONFIG_MBEDTLS_PKCS7_C). A user sdkconfig_options value takes precedence.""" + _mbedtls_sdkconfig().pkcs7_required = True - Call this from components that need PKCS#7 certificate validation. - This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled. + +def require_mbedtls_tls_server() -> None: + """Widen the TLS role to include the server-side handshake. + + 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_PKCS7_REQUIRED] = True + _mbedtls_sdkconfig().tls_server_required = True + + +def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: + """Mark TLS features disabled by ``disable_mbedtls_tls_extras`` as required. + + ``options`` names the entries of ``MBEDTLS_TLS_EXTRA_OPTIONS`` to keep; + omit it to keep all of them. Call this from components that need AES-CCM, + deterministic ECDSA signing, static RSA/ECDH key exchange, TLS + renegotiation or session tickets, or that run a TLS client against + servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied + sdkconfig_options value is never overridden either. + """ + _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: @@ -1929,6 +2164,9 @@ FRAMEWORK_SCHEMA = cv.Schema( *ESP32_CHIP_REVISIONS, string=True ), cv.Optional(CONF_SRAM1_AS_IRAM, default=False): cv.boolean, + cv.Optional(CONF_FLASH_CHIP): cv.one_of( + FLASH_CHIP_GENERIC, *ESP32_FLASH_CHIPS, lower=True + ), # DHCP server is needed for WiFi AP mode. When WiFi component is used, # it will handle disabling DHCP server when AP is not configured. # Default to false (disabled) when WiFi is not used. @@ -1953,6 +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 ), @@ -1987,6 +2226,9 @@ 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, cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean, cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean, @@ -2277,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. @@ -2302,6 +2558,138 @@ async def _reconcile_certificate_bundle_sdkconfig() -> None: set_idf_sdkconfig_default("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN", True) +# TLS features an HTTPS/MQTT client talking to a modern server never +# negotiates. Static RSA and static ECDH key exchange have no forward secrecy +# and are gone in TLS 1.3, renegotiation is deprecated, esp-tls never enables +# session tickets, AES-CCM ciphersuites are not offered by web servers, and +# deterministic ECDSA only matters when signing with a private key. Together +# they cost ~10 KB of flash whenever TLS is linked (http_request, mqtt). +# wpa_supplicant's EAP client is a second TLS client that talks to RADIUS +# servers ESPHome cannot vet, and a failed EAP handshake leaves the device +# off the network, so the wifi component re-enables all of these when eap is +# configured. +# The EC public key parsing extras stay enabled: they decide whether a peer +# certificate with a compressed point or explicit curve parameters parses, +# which no component can know ahead of time. +MBEDTLS_TLS_EXTRA_OPTIONS = ( + "CONFIG_MBEDTLS_KEY_EXCHANGE_RSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_ECDSA", + "CONFIG_MBEDTLS_KEY_EXCHANGE_ECDH_RSA", + "CONFIG_MBEDTLS_SSL_RENEGOTIATION", + "CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS", + "CONFIG_MBEDTLS_CCM_C", + "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC", +) + +# Members of the mbedTLS "TLS Protocol Role" Kconfig choice. Setting one +# member is only valid when the user has not already chosen another. +MBEDTLS_TLS_ROLE_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_MBEDTLS_TLS_DISABLED", +) + + +# 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_") + + +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 = _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) + + # 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 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: """Reconcile WiFi/Ethernet/Bluetooth/coexistence sdkconfig flags. @@ -2509,6 +2897,13 @@ async def to_code(config): # NVS finds stored preferences by key, so preference key migration is possible cg.add_define("USE_PREFERENCE_KEY_LOOKUP") cg.add_build_flag("-Wl,-z,noexecstack") + # assert(), HAL_ASSERT and ESP_ERROR_CHECK bake __FILE__ into rodata, and + # IDF's noflash placement puts the flash driver's copies in DRAM. The + # basename keeps the panic output useful at a fraction of the size. + # __FILE_NAME__ is a GCC 12 builtin; IDF 5.0 still ships GCC 11.2. + if idf_version() >= cv.Version(5, 1, 0): + cg.add_build_flag("-D__FILE__=__FILE_NAME__") + cg.add_build_flag("-Wno-builtin-macro-redefined") # Deferred so KEY_COMPONENTS is fully populated -- see the coroutine. CORE.add_job(_finalize_arduino_aware_flags) cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -2566,6 +2961,17 @@ async def to_code(config): else: for symbol in ("vprintf", "printf", "fprintf", "vfprintf"): cg.add_build_flag(f"-Wl,--wrap={symbol}") + # esp_http_client calls vasprintf, which on the ESP32-C6 is the only + # reference to newlib's full printf engine (~20 KB: _svfprintf_r, + # _dtoa_r and their helpers); every other caller resolves to the + # ROM. See vasprintf_stubs.cpp. The --undefined flag is needed + # because libsrc.a is scanned before the IDF libraries that + # reference the symbol, so the stub would otherwise never be pulled + # from the archive. + if variant in ROM_VSNPRINTF_WITHOUT_VASPRINTF_VARIANTS: + cg.add_define("USE_ESP32_VASPRINTF_STUB") + cg.add_build_flag("-Wl,--wrap=vasprintf") + cg.add_build_flag("-Wl,--undefined=__wrap_vasprintf") else: cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ARDUINO") @@ -2614,6 +3020,8 @@ async def to_code(config): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True ) + # the opi mode choice only exists once octal flash is enabled + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_OCT_FLASH", flash_mode == "opi") if flash_frequency := config.get(CONF_FLASH_FREQUENCY): add_idf_sdkconfig_option( f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True @@ -2627,6 +3035,8 @@ 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. @@ -2643,6 +3053,11 @@ async def to_code(config): add_idf_sdkconfig_option(flag, rev == min_rev) cg.add_define("USE_ESP32_MIN_CHIP_REVISION_SET") + # Keep only the flash vendor driver the board needs; the boot log names it + if (flash_chip := conf[CONF_ADVANCED].get(CONF_FLASH_CHIP)) is not None: + for chip, flag in ESP32_FLASH_CHIPS.items(): + add_idf_sdkconfig_option(flag, chip == flash_chip) + # Use SRAM1 region as IRAM on ESP32 (original) variant # This provides an additional 40KB of IRAM by using SRAM1 memory that was previously # reserved for bootloader DRAM. Requires a bootloader from ESP-IDF v5.1 or later. @@ -2741,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 @@ -2885,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 @@ -2955,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) @@ -2996,6 +3394,15 @@ 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 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. CORE.add_job( @@ -3027,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 @@ -3268,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 @@ -3281,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) @@ -3293,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(): @@ -3336,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 e7d8a66e7a..33751ea7d5 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -3,18 +3,11 @@ import esphome.codegen as cg # Re-exported for the many esp32-side users; defined in esphome.const # and esphome.espidf so the upload/logs fast path can use them without # importing this package. -from esphome.const import ( # noqa: F401 # pylint: disable=unused-import - KEY_ESP32, - KEY_FLASH_SIZE, - KEY_IDF_VERSION, - KEY_VARIANT, -) +from esphome.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION, KEY_VARIANT # noqa: F401 # pylint: disable=unused-import # Back compat for external components only; in-tree callers import it # from esphome.espidf directly. -from esphome.espidf import ( # noqa: F401 # pylint: disable=unused-import - variant_to_idf_target, -) +from esphome.espidf import variant_to_idf_target # noqa: F401 # pylint: disable=unused-import KEY_BOARD = "board" KEY_SDKCONFIG_OPTIONS = "sdkconfig_options" @@ -30,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 a6916fe739..f718d16df4 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 74665f3126..a00c984f76 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/vasprintf_stubs.cpp b/esphome/components/esp32/vasprintf_stubs.cpp new file mode 100644 index 0000000000..308a58ebda --- /dev/null +++ b/esphome/components/esp32/vasprintf_stubs.cpp @@ -0,0 +1,53 @@ +/* + * Linker wrap stub for vasprintf() on variants whose ROM exports a + * full-format vsnprintf() but no vasprintf() (ESP32-C6, newlib only). + * + * On those chips every snprintf/vsnprintf call in the image resolves to + * the ROM, so the newlib printf engine (_svfprintf_r, _dtoa_r and their + * helpers, ~20 KB) is not linked at all until something references a + * printf-family function the ROM lacks. esp_http_client does exactly that + * through vasprintf() in its header and auth helpers, so adding + * http_request to a build costs the whole engine on top of the HTTP and + * TLS code itself. + * + * This stub reimplements vasprintf() on top of the ROM vsnprintf(), which + * keeps the engine out of the image. It is only compiled in when codegen + * defines USE_ESP32_VASPRINTF_STUB, which is gated on the variant's ROM + * linker script and on the same newlib condition as printf_stubs.cpp. + */ + +#include "esphome/core/defines.h" + +#if defined(USE_ESP_IDF) && defined(USE_ESP32_VASPRINTF_STUB) + +#include +#include +#include + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" { + +int __wrap_vasprintf(char **strp, const char *fmt, va_list ap) { + va_list ap_copy; + va_copy(ap_copy, ap); + int len = vsnprintf(nullptr, 0, fmt, ap_copy); + va_end(ap_copy); + if (len < 0) { + return len; + } + // vasprintf's contract is a malloc'd buffer the caller releases with free() + char *buf = static_cast(malloc(static_cast(len) + 1)); // NOLINT(cppcoreguidelines-no-malloc) + if (buf == nullptr) { + return -1; + } + vsnprintf(buf, static_cast(len) + 1, fmt, ap); + *strp = buf; + return len; +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP_IDF && USE_ESP32_VASPRINTF_STUB diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 7e97111686..ef95243bc1 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 81fa328c16..a44886b8c8 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 fd4fb15ff6..950ae6e0bc 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 ab728f9f6f..47e4b7ebb2 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 986778de57..a7132a5465 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 d8095cd702..9355d58859 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -3,6 +3,7 @@ import encodings from esphome import automation import esphome.codegen as cg from esphome.components import esp32_ble +from esphome.components.const import CONF_DESCRIPTION, CONF_MANUFACTURER from esphome.components.esp32 import request_bluetooth from esphome.components.esp32_ble import BTLoggers, bt_uuid import esphome.config_validation as cv @@ -36,12 +37,10 @@ CONF_ADVERTISE = "advertise" CONF_APPEARANCE = "appearance" CONF_BROADCAST = "broadcast" CONF_CHARACTERISTICS = "characteristics" -CONF_DESCRIPTION = "description" CONF_DESCRIPTORS = "descriptors" CONF_ENDIANNESS = "endianness" CONF_FIRMWARE_VERSION = "firmware_version" CONF_INDICATE = "indicate" -CONF_MANUFACTURER = "manufacturer" CONF_MANUFACTURER_DATA = "manufacturer_data" CONF_MAX_CLIENTS = "max_clients" CONF_ON_WRITE = "on_write" @@ -597,7 +596,7 @@ async def to_code(config): cg.add(parent.advertising_set_appearance(config[CONF_APPEARANCE])) cg.add(var.set_max_clients(config[CONF_MAX_CLIENTS])) # Only advertise for the server itself when the configuration gives clients something to - # find. A server that is auto-loaded purely to host a runtime service (esp32_improv) stays + # find. A server that is auto-loaded purely to host a runtime service (improv_ble) stays # silent until that service asks for advertising. cg.add( var.set_advertising_required( diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 45679b9b98..e34a57cf37 100644 --- a/esphome/components/esp32_ble_server/ble_server.cpp +++ b/esphome/components/esp32_ble_server/ble_server.cpp @@ -10,7 +10,7 @@ #include #include #include -#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#ifndef CONFIG_BT_CONTROLLER_DISABLED #include #endif #include diff --git a/esphome/components/esp32_ble_server/ble_server.h b/esphome/components/esp32_ble_server/ble_server.h index 7869c73cc5..e469b60e08 100644 --- a/esphome/components/esp32_ble_server/ble_server.h +++ b/esphome/components/esp32_ble_server/ble_server.h @@ -40,7 +40,7 @@ class BLEServer final : public Component, public Parented { /** Whether this server needs the device to advertise so clients can find and connect to it. * - * False for a server that only hosts services created at runtime (e.g. esp32_improv), which + * False for a server that only hosts services created at runtime (e.g. improv_ble), which * request advertising themselves for as long as they need it. */ void set_advertising_required(bool required) { this->advertising_required_ = required; } diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 906144e5fd..59ac445cc0 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 541b63b2fd..f30f63a5c8 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 0000000000..5cedc22d06 --- /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 e25b6f59fa..9bb90026e3 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 1a424a4a8e..6b71480ed3 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -135,7 +135,7 @@ class ESPBTClient : public ESPBTDeviceListener { void set_tracker_state_version(uint8_t *version) { this->tracker_state_version_ = version; } // Memory optimized layout - uint8_t app_id; // App IDs are small integers assigned sequentially + uint8_t app_id{0}; // App IDs are small integers assigned sequentially protected: /// Set state without IDLE handling - use for direct state transitions. @@ -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_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 598fe61d46..03fdbc4de7 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -433,25 +433,10 @@ void ESP32Camera::set_pixel_format(ESP32CameraPixelFormat format) { } } void ESP32Camera::set_jpeg_quality(uint8_t quality) { this->config_.jpeg_quality = quality; } -void ESP32Camera::set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } -void ESP32Camera::set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } -void ESP32Camera::set_contrast(int contrast) { this->contrast_ = contrast; } -void ESP32Camera::set_brightness(int brightness) { this->brightness_ = brightness; } -void ESP32Camera::set_saturation(int saturation) { this->saturation_ = saturation; } -void ESP32Camera::set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* set exposure parameters */ -void ESP32Camera::set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } -void ESP32Camera::set_aec2(bool aec2) { this->aec2_ = aec2; } -void ESP32Camera::set_ae_level(int ae_level) { this->ae_level_ = ae_level; } -void ESP32Camera::set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* set gains parameters */ -void ESP32Camera::set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } -void ESP32Camera::set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } -void ESP32Camera::set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* set white balance */ -void ESP32Camera::set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* set test mode */ -void ESP32Camera::set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* set fps */ void ESP32Camera::set_max_update_interval(uint32_t max_update_interval) { this->max_update_interval_ = max_update_interval; diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 83dab5f77a..9ff309ad4a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -140,25 +140,25 @@ class ESP32Camera final : public camera::Camera { void set_pixel_format(ESP32CameraPixelFormat format); void set_frame_size(ESP32CameraFrameSize size); void set_jpeg_quality(uint8_t quality); - void set_vertical_flip(bool vertical_flip); - void set_horizontal_mirror(bool horizontal_mirror); - void set_contrast(int contrast); - void set_brightness(int brightness); - void set_saturation(int saturation); - void set_special_effect(ESP32SpecialEffect effect); + void set_vertical_flip(bool vertical_flip) { this->vertical_flip_ = vertical_flip; } + void set_horizontal_mirror(bool horizontal_mirror) { this->horizontal_mirror_ = horizontal_mirror; } + void set_contrast(int contrast) { this->contrast_ = contrast; } + void set_brightness(int brightness) { this->brightness_ = brightness; } + void set_saturation(int saturation) { this->saturation_ = saturation; } + void set_special_effect(ESP32SpecialEffect effect) { this->special_effect_ = effect; } /* -- exposure */ - void set_aec_mode(ESP32GainControlMode mode); - void set_aec2(bool aec2); - void set_ae_level(int ae_level); - void set_aec_value(uint32_t aec_value); + void set_aec_mode(ESP32GainControlMode mode) { this->aec_mode_ = mode; } + void set_aec2(bool aec2) { this->aec2_ = aec2; } + void set_ae_level(int ae_level) { this->ae_level_ = ae_level; } + void set_aec_value(uint32_t aec_value) { this->aec_value_ = aec_value; } /* -- gains */ - void set_agc_mode(ESP32GainControlMode mode); - void set_agc_value(uint8_t agc_value); - void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling); + void set_agc_mode(ESP32GainControlMode mode) { this->agc_mode_ = mode; } + void set_agc_value(uint8_t agc_value) { this->agc_value_ = agc_value; } + void set_agc_gain_ceiling(ESP32AgcGainCeiling gain_ceiling) { this->agc_gain_ceiling_ = gain_ceiling; } /* -- white balance */ - void set_wb_mode(ESP32WhiteBalanceMode mode); + void set_wb_mode(ESP32WhiteBalanceMode mode) { this->wb_mode_ = mode; } /* -- test */ - void set_test_pattern(bool test_pattern); + void set_test_pattern(bool test_pattern) { this->test_pattern_ = test_pattern; } /* -- framerates */ void set_max_update_interval(uint32_t max_update_interval); void set_idle_update_interval(uint32_t idle_update_interval); diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index 6943efc3cb..d78941ea4c 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,24 +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_now_hosted.cpp includes esp_now.h, which esp_wifi provides - esp32.include_builtin_idf_component("esp_wifi") # esp-hosted's CustomRpc ("peer data transfer") path — off by default. esp32.add_idf_sdkconfig_option( "CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True @@ -299,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 4eb5d1745b..c35301a685 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -26,6 +26,8 @@ static const char *const TAG = "esp32_hosted.update"; // Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; +// OTA begin blocks while the coprocessor erases its partition +constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000; #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // Interval/timeout IDs (uint32_t to avoid string comparison) @@ -169,7 +171,7 @@ void Esp32HostedUpdate::dump_config() { ESP_LOGCONFIG(TAG, " Mode: HTTP\n" " Source URL: %s", - this->source_url_.c_str()); + this->source_url_); #else ESP_LOGCONFIG(TAG, " Mode: Embedded\n" @@ -215,7 +217,7 @@ bool Esp32HostedUpdate::fetch_manifest_() { auto container = this->http_request_parent_->get(this->source_url_); if (container == nullptr || container->status_code != 200) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this->source_url_); this->status_set_error(LOG_STR("Failed to fetch manifest")); return false; } @@ -336,7 +338,11 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { ESP_LOGI(TAG, "Firmware size: %zu bytes", total_size); // Begin OTA on coprocessor - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + esp_err_t err; + { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + err = esp_hosted_slave_ota_begin(); // NOLINT + } if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); container->end(); @@ -425,7 +431,11 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() { ESP_LOGI(TAG, "Starting OTA update (%zu bytes)", this->firmware_size_); - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + esp_err_t err; + { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + err = esp_hosted_slave_ota_begin(); // NOLINT + } if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); this->status_set_error(LOG_STR("Failed to begin OTA")); @@ -472,8 +482,6 @@ void Esp32HostedUpdate::perform(bool force) { this->update_info_.has_progress = false; this->publish_state(); - watchdog::WatchdogManager watchdog(60000); - #ifdef USE_ESP32_HOSTED_HTTP_UPDATE if (!this->stream_firmware_to_coprocessor_()) #else diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.h b/esphome/components/esp32_hosted/update/esp32_hosted_update.h index 4f9d04738d..c319852bff 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.h +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.h @@ -25,7 +25,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode setters - void set_source_url(const std::string &url) { this->source_url_ = url; } + void set_source_url(const char *url) { this->source_url_ = url; } void set_http_request_parent(http_request::HttpRequestComponent *parent) { this->http_request_parent_ = parent; } #else // Embedded mode setters @@ -38,7 +38,7 @@ class Esp32HostedUpdate final : public update::UpdateEntity, public PollingCompo #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // HTTP mode members http_request::HttpRequestComponent *http_request_parent_{nullptr}; - std::string source_url_; + const char *source_url_{nullptr}; // literal from codegen std::string firmware_url_; // HTTP mode helpers diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 19dbb68f29..e4d8dfc492 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -5,6 +5,7 @@ import re import subprocess from typing import Any +from esphome.build_helpers.native import native_backend import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import ( @@ -14,6 +15,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_PLATFORM_VERSION, CONF_SOURCE, + CONF_TOOLCHAIN, CONF_VERSION, KEY_CORE, KEY_FRAMEWORK_VERSION, @@ -21,6 +23,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, PLATFORM_ESP8266, ThreadModel, + Toolchain, ) from esphome.core import ( CORE, @@ -37,13 +40,16 @@ from esphome.types import ConfigType from .boards import BOARDS, board_ld_script from .const import ( + BUILD_FLASH_MODES, CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, CONF_ENABLE_SERIAL1, CONF_RESTORE_FROM_FLASH, KEY_BOARD, + KEY_DECODE_TOOLS, KEY_ESP8266, KEY_PIN_INITIAL_STATES, + KEY_SCANF_FLOAT, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, KEY_WAVEFORM_REQUIRED, @@ -103,6 +109,59 @@ def set_core_data(config: ConfigType) -> ConfigType: return config +_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO) +_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) +_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO) + + +def _warn_platformio_toolchain(config: ConfigType) -> ConfigType: + # Remove before 2027.4.0 + if CORE.using_toolchain_platformio: + _LOGGER.warning( + "The 'platformio' toolchain for ESP8266 is deprecated and will be " + "removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the " + "default." + ) + return config + + +def _validate_native_toolchain(config: ConfigType) -> ConfigType: + """Constraints of the native (non-PlatformIO) Arduino toolchain.""" + if not CORE.using_toolchain_arduino: + return config + from esphome.arduino8266.framework import framework_release + + conf = config[CONF_FRAMEWORK] + version = cv.Version.parse(conf[CONF_VERSION]) + try: + framework_release(version) + except EsphomeError as err: + raise cv.Invalid(str(err), path=[CONF_FRAMEWORK, CONF_VERSION]) from err + # platform_version is a PlatformIO concept; drop it, warning when a + # custom pin is discarded + if ( + conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC) + != _ARDUINO_4_PLATFORM_SPEC + ): + _LOGGER.warning( + "'platform_version' is ignored by 'toolchain: arduino'; the native " + "toolchain downloads the framework and compiler directly" + ) + # The native path fetches its own build; source may only be the + # PlatformIO default the schema filled in + if conf[CONF_SOURCE] != _format_framework_arduino_version(version): + raise cv.Invalid( + "'toolchain: arduino' does not support a custom framework source; " + "use 'toolchain: platformio'" + ) + if config[CONF_BOARD] not in BOARDS: + raise cv.Invalid( + f"Board '{config[CONF_BOARD]}' is not supported by " + "'toolchain: arduino'; use 'toolchain: platformio'" + ) + return config + + def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: """Binary-download entries for a built ESP8266 firmware. @@ -127,16 +186,27 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]: ] +def _framework_package_version(ver: cv.Version) -> str: + """Map an Arduino core version to its PlatformIO registry package version + (3.1.2 -> 3.30102.0; the leading 3 is the package major).""" + if ver.major > 3: + raise EsphomeError( + f"Arduino core {ver} is not supported yet; " + "the newest known core series is 3.x" + ) + if ver.major < 3: + raise EsphomeError( + f"Arduino core {ver} is not supported; ESPHome requires core 3.x" + ) + return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + + def _format_framework_arduino_version(ver: cv.Version) -> str: # format the given arduino (https://github.com/esp8266/Arduino/releases) version to # a PIO platformio/framework-arduinoespressif8266 value # List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266 - # Same encoding the native toolchain uses for its package download, so a - # version bump cannot drift between the two paths. - from esphome.arduino8266.framework import framework_package_version - try: - return f"~{framework_package_version(ver)}" + return f"~{_framework_package_version(ver)}" except EsphomeError as err: # Anchor the 4.x rejection to the framework version line instead of # aborting with a bare traceback-level error @@ -147,7 +217,8 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # * New framework historically have had some regressions, especially for WiFi. # The new version needs to be thoroughly validated before changing the # recommended version as otherwise a bunch of devices could be bricked -# * For all constants below, update platformio.ini (in this repo) +# * For all constants below, update platformio.ini (in this repo) and +# FRAMEWORK_RELEASES in esphome/arduino8266/framework.py # The default/recommended arduino framework version # - https://github.com/esp8266/Arduino/releases @@ -194,7 +265,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType: platform_version = value.get(CONF_PLATFORM_VERSION) if platform_version is None: if version >= cv.Version(3, 1, 0): - platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION)) + platform_version = _ARDUINO_4_PLATFORM_SPEC else: platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION)) value[CONF_PLATFORM_VERSION] = platform_version @@ -217,6 +288,10 @@ def _parse_platform_version(value: Any) -> str: return value +# The platform_version derived for every core >= 3.1.0 config +_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION)) + + ARDUINO_FRAMEWORK_SCHEMA = cv.All( cv.Schema( { @@ -233,7 +308,6 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All( ) -BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"] CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -250,11 +324,14 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean, cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean, cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, + cv.Optional( + CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED + ): _validate_toolchain, } ), - # Until the native toolchain lands, PlatformIO is the only backend; - # reject a --toolchain this platform cannot serve yet. - cv.require_platformio_toolchain("ESP8266"), + _resolve_toolchain, + _warn_platformio_toolchain, + _validate_native_toolchain, set_core_data, ) @@ -262,8 +339,8 @@ CONFIG_SCHEMA = cv.All( def check_rosetta() -> None: """Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac. - There is no native arm64 build of the xtensa-lx106 toolchain; on Apple - Silicon it runs under Rosetta 2, which macOS updates can remove. + PlatformIO's xtensa-lx106 toolchain has no arm64 build; on Apple Silicon + it runs under Rosetta 2, which macOS updates can remove. """ if not IS_MACOS or platform.machine() != "arm64": return @@ -278,9 +355,10 @@ def check_rosetta() -> None: return # arch(1) unavailable; let the build proceed if result.returncode != 0: raise EsphomeError( - "ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) " - "compiler that requires Rosetta 2, which is not installed on " - "this system. Install it with:\n" + "ESP8266 builds with PlatformIO on Apple Silicon Macs use an Intel " + "(x86_64) compiler that requires Rosetta 2, which is not installed " + "on this system. Either set 'toolchain: arduino' under 'esp8266:', " + "which needs no Rosetta, or install it with:\n" " softwareupdate --install-rosetta --agree-to-license" ) @@ -294,12 +372,13 @@ def _choose_ld_script(board: str) -> str: @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config: ConfigType) -> None: + use_platformio = CORE.using_toolchain_platformio cg.add(esp8266_ns.setup_preferences()) - cg.add_platformio_option("lib_ldf_mode", "off") - cg.add_platformio_option("lib_compat_mode", "strict") - - cg.add_platformio_option("board", config[CONF_BOARD]) + if use_platformio: + cg.add_platformio_option("lib_ldf_mode", "off") + cg.add_platformio_option("lib_compat_mode", "strict") + cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_build_flag("-DUSE_ESP8266") cg.set_cpp_standard("gnu++20") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -315,28 +394,32 @@ async def to_code(config: ConfigType) -> None: "enabling scanf float support (~8KB flash)" ) - extra_scripts = [ - "pre:ccache.py", - "pre:testing_mode.py", - "pre:exclude_updater.py", - "pre:exclude_waveform.py", - "pre:relocate_ratetable.py", - ] - if not enable_scanf_float: - extra_scripts.append("pre:remove_float_scanf.py") - extra_scripts.append("post:post_build.py") - cg.add_platformio_option("extra_scripts", extra_scripts) + # The native generator reads the same decision (KEY_SCANF_FLOAT) + CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float) + if use_platformio: + extra_scripts = [ + "pre:ccache.py", + "pre:testing_mode.py", + "pre:exclude_updater.py", + "pre:exclude_waveform.py", + "pre:relocate_ratetable.py", + ] + if not enable_scanf_float: + extra_scripts.append("pre:remove_float_scanf.py") + extra_scripts.append("post:post_build.py") + cg.add_platformio_option("extra_scripts", extra_scripts) conf = config[CONF_FRAMEWORK] - cg.add_platformio_option("framework", "arduino") cg.add_build_flag("-DUSE_ARDUINO") cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO") cg.add_build_flag("-Wno-nonnull-compare") - cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) - cg.add_platformio_option( - "platform_packages", - [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], - ) + if use_platformio: + cg.add_platformio_option("framework", "arduino") + cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) + cg.add_platformio_option( + "platform_packages", + [f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"], + ) # Default for platformio is LWIP2_LOW_MEMORY with: # - MSS=536 @@ -363,18 +446,10 @@ async def to_code(config: ConfigType) -> None: if config.get(CONF_ENABLE_SERIAL1): enable_serial1() - # Arduino 2 has a non-standards conformant new that returns a nullptr instead of failing when - # out of memory and exceptions are disabled. Since Arduino 2.6.0, this flag can be used to make - # new abort instead. Use it so that OOM fails early (on allocation) instead of on dereference of - # a NULL pointer (so the stacktrace makes more sense), and for consistency with Arduino 3, - # which always aborts if exceptions are disabled. - # For cases where nullptrs can be handled, use nothrow: `new (std::nothrow) T;` - cg.add_build_flag("-DNEW_OOM_ABORT") - # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. - # See throw_stubs.h for details. Must be prepended before , so this - # uses build_src_flags with -include. + # See throw_stubs.h. Unconditional: the native build generator reads + # the same option, keeping one source of truth. cg.add_platformio_option( "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" ) @@ -404,6 +479,8 @@ async def to_code(config: ConfigType) -> None: # implementation in the Arduino ESP8266 core. cg.add_build_flag("-Wl,--wrap=millis") + # Unconditional: the native build generator reads the same option, + # keeping one source of truth cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE]) ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] @@ -412,7 +489,7 @@ async def to_code(config: ConfigType) -> None: cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"), ) - if config[CONF_BOARD] in BOARDS: + if use_platformio and config[CONF_BOARD] in BOARDS: cg.add_platformio_option( "board_build.ldscript", _choose_ld_script(config[CONF_BOARD]) ) @@ -453,8 +530,24 @@ async def finalize_serial_config() -> None: cg.add_build_flag("-DNO_GLOBAL_SERIAL1") +# Called by __main__.compile_program; returning False falls through to the +# PlatformIO toolchain. +def run_compile(args, config: ConfigType) -> bool: + # Positive check: the native backend only runs when explicitly resolved + toolchain = native_backend() + if toolchain is None: + return False + if toolchain.run_compile(config, CORE.verbose) != 0: + raise EsphomeError("ESP8266 native build failed") + return True + + # Called by writer.py def copy_files() -> None: + # Native builds skip the PlatformIO extra scripts; the build generator + # carries their logic + if CORE.using_toolchain_arduino: + return dir = Path(__file__).parent for script in ( "post_build", @@ -511,22 +604,54 @@ ESP8266_EXCEPTION_CODES = { } -def _decode_pc(config: ConfigType, addr: str) -> None: +def _resolve_decode_tools(config: ConfigType) -> tuple[str, str] | None: + """``(addr2line, elf)`` for this build, or None after warning why.""" + if (native_toolchain := native_backend()) is not None: + addr2line = native_toolchain.get_addr2line_path() + elf = native_toolchain.get_elf_path() + for path in (addr2line, elf): + if not path.is_file(): + _LOGGER.warning("Cannot decode crash addresses: %s missing", path) + return None + return str(addr2line), str(elf) from esphome.platformio import toolchain idedata = toolchain.get_idedata(config) if not idedata.addr2line_path or not idedata.firmware_elf_path: - _LOGGER.debug("decode_pc no addr2line") + _LOGGER.warning("Cannot decode crash addresses: no addr2line or ELF in idedata") + return None + return idedata.addr2line_path, idedata.firmware_elf_path + + +def _decode_tools(config: ConfigType) -> tuple[str, str] | None: + """Resolved once per run: the tools are a property of the build, not of + the address, so a stack dump cannot repeat the failure warning.""" + data = CORE.data.setdefault(KEY_ESP8266, {}) + if KEY_DECODE_TOOLS not in data: + data[KEY_DECODE_TOOLS] = _resolve_decode_tools(config) + return data[KEY_DECODE_TOOLS] + + +def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None: + """Decode one crash address. ``bulk``: the caller is scanning every + 8-hex stack word, most of which are not code addresses -- unmappable + ones log at debug so real frames are not buried.""" + # Bulk stack words are ~150 per dump; only named registers warn + log = _LOGGER.debug if bulk else _LOGGER.warning + if (tools := _decode_tools(config)) is None: + log("Not decoded %s (no addr2line or ELF)", addr) return - command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr] + addr2line, elf = tools + command = [addr2line, "-pfiaC", "-e", elf, addr] try: translation = subprocess.check_output(command, close_fds=False).decode().strip() - except Exception: # noqa: BLE001 # pylint: disable=broad-except + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + log("Could not decode crash address %s (%s)", addr, err) _LOGGER.debug("Caught exception for command %s", command, exc_info=1) return if "?? ??:0" in translation: - # Nothing useful + log("Not decoded %s (address not in %s)", addr, elf) return translation = translation.replace(" at ??:?", "").replace(":?", "") _LOGGER.warning("Decoded %s", translation) @@ -596,6 +721,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> if backtrace_state: for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line): - _decode_pc(config, addr.group()) + _decode_pc(config, addr.group(), bulk=True) return backtrace_state diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 50f103ed2d..bf4dd1a358 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 a85f054dfe..66ac122f00 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 79c2e50984..7b36c410a2 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -4,13 +4,15 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" -#include "esphome/core/automation.h" #include "esphome/components/output/float_output.h" namespace esphome::esp8266_pwm { class ESP8266PWM final : public output::FloatOutput, public Component { public: + // User provided, not "= default": `new(p) ESP8266PWM()` would zero-fill .bss that is already zero. + ESP8266PWM() {} + void set_pin(InternalGPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } /// Dynamically update frequency @@ -28,25 +30,12 @@ class ESP8266PWM final : public output::FloatOutput, public Component { protected: void write_state(float state) override; - InternalGPIOPin *pin_; + InternalGPIOPin *pin_{nullptr}; float frequency_{1000.0}; // Keep in sync with DEFAULT_FREQUENCY in output.py /// Cache last output level for dynamic frequency updating float last_output_{0.0}; }; -template class SetFrequencyAction final : public Action { - public: - SetFrequencyAction(ESP8266PWM *parent) : parent_(parent) {} - TEMPLATABLE_VALUE(float, frequency); - - void play(const Ts &...x) { - float freq = this->frequency_.value(x...); - this->parent_->update_frequency(freq); - } - - ESP8266PWM *parent_; -}; - } // namespace esphome::esp8266_pwm #endif diff --git a/esphome/components/esp8266_pwm/output.py b/esphome/components/esp8266_pwm/output.py index be6e63b154..62ec1fe003 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 46810d422d..23536fe3a2 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 0451c338dd..0f0131d4d5 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 0000000000..a46d6507fd --- /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 0000000000..c012f823cf --- /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 0000000000..c6a167324e --- /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 0000000000..f89c42f8fe --- /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 0000000000..0cce6a3b94 --- /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 0000000000..e526c80cb4 --- /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 0000000000..8a4835e58b --- /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 0000000000..9569b2955b --- /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 0000000000..3d27cfabf9 --- /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 0000000000..c0efac6204 --- /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 0000000000..0edc3c0728 --- /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 0000000000..fde1c46113 --- /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 bace13c17f..0cb2657ea1 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,6 +341,10 @@ 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 {} @@ -320,7 +352,7 @@ async def to_code(config: ConfigType) -> None: # 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], 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") diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index d353d01d20..c399c0901c 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -22,8 +22,10 @@ #include "esphome/core/lwip_fast_select.h" #endif +#include #include #include +#include #include namespace esphome { @@ -47,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) @@ -194,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 @@ -305,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 @@ -325,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] = @@ -343,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])) { @@ -442,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; @@ -459,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); @@ -467,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) } @@ -475,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) { @@ -501,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) @@ -521,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); } } @@ -666,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; @@ -712,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]; @@ -786,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; @@ -793,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 92ba094c8d..910a5b3d99 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" @@ -89,6 +92,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent { // 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_(); @@ -120,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); @@ -134,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_(); @@ -174,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; @@ -186,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 0000000000..1208f82ae8 --- /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 0000000000..9726508a30 --- /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 0000000000..f3005cac48 --- /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 65476572a1..b3d8501f10 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 14d099ec06..dc36885f2d 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 e4d01bb1a8..851b8345c9 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 af693b47cf..375bd4faef 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 3e7d345805..ff05caca83 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 2b67b9093b..44c4d6733d 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 ea732e84ff..e9281453c9 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,10 @@ #include #include #include "esp_event.h" +#if USE_NETWORK_IPV6 +#include +#include +#endif #ifdef USE_PSRAM #include #endif @@ -41,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) @@ -264,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) @@ -290,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_; @@ -420,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: { @@ -441,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 @@ -492,6 +515,18 @@ void EthernetComponent::ethernet_lazy_init_() { } } #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); @@ -593,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: @@ -734,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"; @@ -770,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); @@ -785,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 @@ -795,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 @@ -812,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(); @@ -891,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 @@ -974,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 @@ -1075,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; @@ -1099,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/ethernet_info/ethernet_info_text_sensor.h b/esphome/components/ethernet_info/ethernet_info_text_sensor.h index 11002d51ba..c9fcda225f 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.h +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.h @@ -13,6 +13,9 @@ class IPAddressEthernetInfo final : public Component, public text_sensor::TextSensor, public ethernet::EthernetIPStateListener { public: + // User provided, not "= default": `new(p) IPAddressEthernetInfo()` would zero-fill .bss that is already zero. + IPAddressEthernetInfo() {} + void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index 881107b713..d02863b9ee 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 73a6336f78..0000000000 --- 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 673ccc9802..00bb9497f9 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 e6fc7111c8..f57327e33f 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 0000000000..c58ce8a01e --- /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 0000000000..243e2cf31e --- /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 0000000000..98bd88553b --- /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 0000000000..20c8135636 --- /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 0793495e1a..1157ef1fb0 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 a81b6c09f0..fcdb94b62b 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 55283f2d09..ab05302a55 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 ed4efeeabc..1ff214de78 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 afae1e5188..47651d0d1d 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 3949f16d2e..526499fe07 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 cbd994e749..4c587904b7 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -6,51 +6,6 @@ namespace esphome::fan { -// All configured fields are baked into a single stateless lambda whose -// constants live in flash. The action only stores one function pointer -// plus one parent pointer, regardless of how many fields the user set. -// Trigger args are forwarded to the apply function so user lambdas -// (e.g. `speed: !lambda "return x;"`) keep working. -// -// Trigger args are normalized to `const std::remove_cvref_t &...` so -// the codegen can emit a matching parameter list for both the apply lambda -// and any inner field lambdas without producing invalid C++ source text -// (e.g. `const T & &` if Ts already carries a reference, or `const const -// T &` if Ts already carries a const). This keeps trigger args no-copy -// regardless of whether the trigger supplies `T`, `T &`, or `const T &`. -template class TurnOnAction final : public Action { - public: - using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t &...); - TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {} - - void play(const Ts &...x) override { - auto call = this->state_->turn_on(); - this->apply_(call, x...); - call.perform(); - } - - Fan *state_; - ApplyFn apply_; -}; - -template class TurnOffAction final : public Action { - public: - explicit TurnOffAction(Fan *state) : state_(state) {} - - void play(const Ts &...x) override { this->state_->turn_off().perform(); } - - Fan *state_; -}; - -template class ToggleAction final : public Action { - public: - explicit ToggleAction(Fan *state) : state_(state) {} - - void play(const Ts &...x) override { this->state_->toggle().perform(); } - - Fan *state_; -}; - template class CycleSpeedAction final : public Action { public: explicit CycleSpeedAction(Fan *state) : state_(state) {} @@ -95,23 +50,6 @@ template class CycleSpeedAction final : public Action { Fan *state_; }; -template class FanIsOnCondition final : public Condition { - public: - explicit FanIsOnCondition(Fan *state) : state_(state) {} - bool check(const Ts &...x) override { return this->state_->state; } - - protected: - Fan *state_; -}; -template class FanIsOffCondition final : public Condition { - public: - explicit FanIsOffCondition(Fan *state) : state_(state) {} - bool check(const Ts &...x) override { return !this->state_->state; } - - protected: - Fan *state_; -}; - class FanStateTrigger final : public Trigger { public: FanStateTrigger(Fan *state) : fan_(state) { diff --git a/esphome/components/fan/fan.h b/esphome/components/fan/fan.h index 106e6e74cd..7e21971639 100644 --- a/esphome/components/fan/fan.h +++ b/esphome/components/fan/fan.h @@ -183,7 +183,7 @@ class Fan : public EntityBase { LazyCallbackManager state_callback_{}; ESPPreferenceObject rtc_; - FanRestoreMode restore_mode_; + FanRestoreMode restore_mode_{FanRestoreMode::NO_RESTORE}; private: /// Lazy-allocate preset modes vector (never freed — entity lives forever). diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 8d935a3c9e..0266a9caad 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 67662192ee..5256d4489e 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 918fde5dbd..732933ba48 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 fda9c269e5..2592370b6a 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 fa24181bd0..bab7b449ba 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 f801239153..9995a3abbd 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 8d2ec883da..20bda883b2 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 4ed081a7be..4fb65d9c25 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 bd6bc5f783..8adf4e983c 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 78d2bc5910..b7acb06569 100644 --- a/esphome/components/globals/globals_component.h +++ b/esphome/components/globals/globals_component.h @@ -1,8 +1,10 @@ #pragma once -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" + +#include #include namespace esphome::globals { @@ -127,20 +129,6 @@ template class RestoringGlobalStringComponent : public P ESPPreferenceObject rtc_; }; -template class GlobalVarSetAction final : public Action { - public: - explicit GlobalVarSetAction(C *parent) : parent_(parent) {} - - using T = typename C::value_type; - - TEMPLATABLE_VALUE(T, value); - - void play(const Ts &...x) override { this->parent_->value() = this->value_.value(x...); } - - protected: - C *parent_; -}; - template T &id(GlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalsComponent *value) { return value->value(); } template T &id(RestoringGlobalStringComponent *value) { return value->value(); } diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h index 956443fab5..80636e29a6 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.h @@ -47,6 +47,9 @@ class GPIOBinarySensorStore { class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Component { public: + // User provided, not "= default": `new(p) GPIOBinarySensor()` would zero-fill .bss that is already zero. + GPIOBinarySensor() {} + // No destructor needed: ESPHome components are created at boot and live forever. // Interrupts are only detached on reboot when memory is cleared anyway. @@ -70,7 +73,7 @@ class GPIOBinarySensor final : public binary_sensor::BinarySensor, public Compon void loop() override; protected: - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; GPIOBinarySensorStore store_; }; diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index d432655a2a..d231b3d77a 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -13,18 +13,10 @@ void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); // write state before setup - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state); } void GPIOSwitch::dump_config() { LOG_SWITCH("", "GPIO Switch", this); diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index 7ed0de7c6f..e7323e6e93 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -9,6 +9,9 @@ namespace esphome::gpio { class GPIOSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) GPIOSwitch()` would zero-fill .bss that is already zero. + GPIOSwitch() {} + void set_pin(GPIOPin *pin) { pin_ = pin; } // ========== INTERNAL METHODS ========== @@ -25,7 +28,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { protected: void write_state(bool state) override; - GPIOPin *pin_; + GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK FixedVector interlock_; uint32_t interlock_wait_time_{0}; diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index f0642d2e8c..d261c48855 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -57,10 +57,6 @@ void GraphicalDisplayMenu::dump_config() { } } -void GraphicalDisplayMenu::set_display(display::Display *display) { this->display_ = display; } - -void GraphicalDisplayMenu::set_font(display::BaseFont *font) { this->font_ = font; } - void GraphicalDisplayMenu::set_foreground_color(Color foreground_color) { this->foreground_color_ = foreground_color; } void GraphicalDisplayMenu::set_background_color(Color background_color) { this->background_color_ = background_color; } diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index ccdf3d304c..13c0f9d73f 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -38,8 +38,8 @@ class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponen void setup() override; void dump_config() override; - void set_display(display::Display *display); - void set_font(display::BaseFont *font); + void set_display(display::Display *display) { this->display_ = display; } + void set_font(display::BaseFont *font) { this->font_ = font; } template void set_menu_item_value(V menu_item_value) { this->menu_item_value_ = menu_item_value; } void set_foreground_color(Color foreground_color); void set_background_color(Color background_color); diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index a794e7721f..e59f186521 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 2f10be3e6b..c261629573 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 ae64c049f5..2d44d88323 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 a8648025b9..08a3ac2374 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 2e2b218730..af323e6b07 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 a81fd4bdb7..0000000000 --- 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 70ae36f528..5c4d498912 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_BEEPER, CONF_CURRENT_TEMPERATURE, CONF_DISPLAY, - CONF_ID, CONF_LEVEL, CONF_LOGGER, CONF_LOGS, @@ -254,23 +253,6 @@ CONFIG_SCHEMA = cv.All( ) -# Actions -DisplayOnAction = haier_ns.class_("DisplayOnAction", automation.Action) -DisplayOffAction = haier_ns.class_("DisplayOffAction", automation.Action) -BeeperOnAction = haier_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = haier_ns.class_("BeeperOffAction", automation.Action) -StartSelfCleaningAction = haier_ns.class_("StartSelfCleaningAction", automation.Action) -StartSteriCleaningAction = haier_ns.class_( - "StartSteriCleaningAction", automation.Action -) -VerticalAirflowAction = haier_ns.class_("VerticalAirflowAction", automation.Action) -HorizontalAirflowAction = haier_ns.class_("HorizontalAirflowAction", automation.Action) -HealthOnAction = haier_ns.class_("HealthOnAction", automation.Action) -HealthOffAction = haier_ns.class_("HealthOffAction", automation.Action) -PowerOnAction = haier_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = haier_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = haier_ns.class_("PowerToggleAction", automation.Action) - HAIER_BASE_ACTION_SCHEMA = automation.maybe_simple_id( { cv.GenerateID(): cv.use_id(HaierClimateBase), @@ -284,62 +266,35 @@ HAIER_HON_BASE_ACTION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "climate.haier.display_on", - DisplayOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.display_off", - DisplayOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def display_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +for _name, _schema, _call in ( + ("climate.haier.display_on", HAIER_BASE_ACTION_SCHEMA, "set_display_state(true)"), + ("climate.haier.display_off", HAIER_BASE_ACTION_SCHEMA, "set_display_state(false)"), + ("climate.haier.beeper_on", HAIER_HON_BASE_ACTION_SCHEMA, "set_beeper_state(true)"), + ( + "climate.haier.beeper_off", + HAIER_HON_BASE_ACTION_SCHEMA, + "set_beeper_state(false)", + ), + ( + "climate.haier.start_self_cleaning", + HAIER_HON_BASE_ACTION_SCHEMA, + "start_self_cleaning()", + ), + ( + "climate.haier.start_steri_cleaning", + HAIER_HON_BASE_ACTION_SCHEMA, + "start_steri_cleaning()", + ), + ("climate.haier.health_on", HAIER_BASE_ACTION_SCHEMA, "set_health_mode(true)"), + ("climate.haier.health_off", HAIER_BASE_ACTION_SCHEMA, "set_health_mode(false)"), + ("climate.haier.power_on", HAIER_BASE_ACTION_SCHEMA, "send_power_on_command()"), + ("climate.haier.power_off", HAIER_BASE_ACTION_SCHEMA, "send_power_off_command()"), + ("climate.haier.power_toggle", HAIER_BASE_ACTION_SCHEMA, "toggle_power()"), +): + automation.register_apply_action(_name, _schema, automation.ApplyCall(_call)) - -@automation.register_action( - "climate.haier.beeper_on", - BeeperOnAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.beeper_off", - BeeperOffAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def beeper_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -# Start self cleaning or steri-cleaning action action -@automation.register_action( - "climate.haier.start_self_cleaning", - StartSelfCleaningAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.start_steri_cleaning", - StartSteriCleaningAction, - HAIER_HON_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def start_cleaning_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -# Set vertical airflow direction action -@automation.register_action( +automation.register_apply_action( "climate.haier.set_vertical_airflow", - VerticalAirflowAction, cv.Schema( { cv.GenerateID(): cv.use_id(HonClimate), @@ -348,22 +303,13 @@ async def start_cleaning_to_code(config, action_id, template_arg, args): ), } ), - synchronous=True, + automation.ApplyField( + CONF_VERTICAL_AIRFLOW, "set_vertical_airflow", AirflowVerticalDirection + ), ) -async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_VERTICAL_AIRFLOW], args, AirflowVerticalDirection - ) - cg.add(var.set_direction(template_)) - return var - -# Set horizontal airflow direction action -@automation.register_action( +automation.register_apply_action( "climate.haier.set_horizontal_airflow", - HorizontalAirflowAction, cv.Schema( { cv.GenerateID(): cv.use_id(HonClimate), @@ -372,56 +318,10 @@ async def haier_set_vertical_airflow_to_code(config, action_id, template_arg, ar ), } ), - synchronous=True, + automation.ApplyField( + CONF_HORIZONTAL_AIRFLOW, "set_horizontal_airflow", AirflowHorizontalDirection + ), ) -async def haier_set_horizontal_airflow_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable( - config[CONF_HORIZONTAL_AIRFLOW], args, AirflowHorizontalDirection - ) - cg.add(var.set_direction(template_)) - return var - - -@automation.register_action( - "climate.haier.health_on", - HealthOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.health_off", - HealthOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def health_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) - - -@automation.register_action( - "climate.haier.power_on", - PowerOnAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.power_off", - PowerOffAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "climate.haier.power_toggle", - PowerToggleAction, - HAIER_BASE_ACTION_SCHEMA, - synchronous=True, -) -async def power_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) def _final_validate(config) -> None: diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 48f72dc16b..87f9331d55 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -190,8 +190,6 @@ void HaierClimateBase::set_supported_presets(climate::ClimatePresetMask presets) this->traits_.add_supported_preset(climate::CLIMATE_PRESET_NONE); } -void HaierClimateBase::set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } - void HaierClimateBase::send_custom_command(const haier_protocol::HaierMessage &message) { this->action_request_ = PendingAction({ActionRequest::SEND_CUSTOM_COMMAND, message}); } diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index db4c1abceb..18ddbcc1cc 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -71,7 +71,7 @@ class HaierClimateBase : public esphome::Component, }; bool can_send_message() const { return haier_protocol_.get_outgoing_queue_size() == 0; }; void set_answer_timeout(uint32_t timeout); - void set_send_wifi(bool send_wifi); + void set_send_wifi(bool send_wifi) { this->send_wifi_signal_ = send_wifi; } void send_custom_command(const haier_protocol::HaierMessage &message); template void add_status_message_callback(F &&callback) { this->status_message_callback_.add(std::forward(callback)); diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index dcea1afd04..d2db05381d 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 2cf1693b47..e862760e6a 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 187b6d2a97..c786f60674 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 681dffe4f6..d3399c8a2d 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 8a19cce8d1..10074fbf10 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 b2b6dc533a..c35488c96c 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 ad0311fb4f..9837b9af33 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 b5388b4c2b..63625bbf94 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 cc5343ee89..0cf8b7a386 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 6d91c3df7c..46e1a95cb1 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 c1aa81f6d4..b8040e5579 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 3bdf6e2c71..8b219f3ed2 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 384477be3d..5099dcc94c 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 7b069d85d0..f2a8ffb216 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 2fa82b2710..79fb81ea5f 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 a2e1f8054a..229d6c8b62 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 4aa2c79bb1..22f0c12c8c 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 83be385c7b..c16a0890ef 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 d3d784928d..f7e40b8921 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 0000000000..da460905c0 --- /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 0000000000..765a80ae6f --- /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 0000000000..a3fb91cd9a --- /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 1b66842f1e..9fead561af 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 6ea17b6831..310ade27a8 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 0000000000..f486a84126 --- /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 0000000000..87280c2b5b --- /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 0000000000..cc7bbddfef --- /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 ab1389e13a..6288db6880 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 965f91d202..e8a679e91c 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 0000000000..e8ad0fc23b --- /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 0000000000..d3401de7f0 --- /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 0000000000..843e07e60e --- /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 abee957fda..1417c57bb5 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 55854cd659..495779b6ed 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 8a4ea19f2e..79d75c9133 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 0000000000..67377e02d0 --- /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 0000000000..31e4919700 --- /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 0000000000..978dfdae01 --- /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 265250c695..f57b398111 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 5250e1c1c7..8ab2cd16b8 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 4b116f0f16..d7e7cf29a4 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 299acd4b52..d4c4e44a3b 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 bd074ab6b5..16e4c81d34 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 9292cd77f6..f753534efd 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 bf9fe4000e..80d5986295 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 de35d52a40..5faf989686 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 d45208ed5d..37e0de5d6a 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 4471dffdc2..68696118ea 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: @@ -331,27 +336,46 @@ class HttpRequestComponent : public Component { void set_follow_redirects(bool follow_redirects) { this->follow_redirects_ = follow_redirects; } void set_redirect_limit(uint16_t limit) { this->redirect_limit_ = limit; } - std::shared_ptr get(const std::string &url) { - return this->start(url, "GET", "", std::vector
{}); - } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + std::shared_ptr get(const char *url) { return this->start(url, "GET", "", std::vector
{}); } + std::shared_ptr get(const char *url, const std::vector
&request_headers) { return this->start(url, "GET", "", request_headers); } - std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + std::shared_ptr get(const char *url, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->start(url, "GET", "", request_headers, lower_case_collect_headers); } - std::shared_ptr post(const std::string &url, const std::string &body) { + std::shared_ptr post(const char *url, const std::string &body) { return this->start(url, "POST", body, std::vector
{}); } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers) { + return this->start(url, "POST", body, request_headers); + } + std::shared_ptr post(const char *url, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + } + + std::shared_ptr get(const std::string &url) { return this->get(url.c_str()); } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers) { + return this->get(url.c_str(), request_headers); + } + std::shared_ptr get(const std::string &url, const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->get(url.c_str(), request_headers, lower_case_collect_headers); + } + std::shared_ptr post(const std::string &url, const std::string &body) { + return this->post(url.c_str(), body); + } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers) { - return this->start(url, "POST", body, request_headers); + return this->post(url.c_str(), body, request_headers); } std::shared_ptr post(const std::string &url, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { - return this->start(url, "POST", body, request_headers, lower_case_collect_headers); + return this->post(url.c_str(), body, request_headers, lower_case_collect_headers); } // Remove before 2027.1.0 @@ -379,11 +403,15 @@ class HttpRequestComponent : public Component { return this->post(url, body, std::vector
(request_headers.begin(), request_headers.end()), collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers) { // Call perform() directly to avoid ambiguity with the deprecated overloads return this->perform(url, method, body, request_headers, {}); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers); + } // Remove before 2027.1.0 ESPDEPRECATED("Pass request_headers as std::vector
instead of std::list. Removed in 2027.1.0.", "2026.7.0") @@ -403,7 +431,7 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, request_headers, lower); + return this->perform(url.c_str(), method.c_str(), body, request_headers, lower); } // Remove before 2027.1.0 @@ -418,7 +446,8 @@ class HttpRequestComponent : public Component { for (const auto &h : collect_headers) { lower.push_back(str_lower_case(h)); // NOLINT } - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), lower); + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower); } // Remove before 2027.1.0 @@ -426,19 +455,25 @@ class HttpRequestComponent : public Component { std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, const std::list
&request_headers, const std::vector &lower_case_collect_headers) { - return this->perform(url, method, body, std::vector
(request_headers.begin(), request_headers.end()), + return this->perform(url.c_str(), method.c_str(), body, + std::vector
(request_headers.begin(), request_headers.end()), lower_case_collect_headers); } - std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr start(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { return this->perform(url, method, body, request_headers, lower_case_collect_headers); } + std::shared_ptr start(const std::string &url, const std::string &method, const std::string &body, + const std::vector
&request_headers, + const std::vector &lower_case_collect_headers) { + return this->start(url.c_str(), method.c_str(), body, request_headers, lower_case_collect_headers); + } protected: - virtual std::shared_ptr perform(const std::string &url, const std::string &method, - const std::string &body, const std::vector
&request_headers, + virtual std::shared_ptr perform(const char *url, const char *method, const std::string &body, + const std::vector
&request_headers, const std::vector &lower_case_collect_headers) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; @@ -499,8 +534,8 @@ template class HttpRequestSendAction final : public Actionparent_->start(this->url_.value(x...), this->method_.value(x...), body, request_headers, - this->lower_case_collect_headers_); + auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, + request_headers, this->lower_case_collect_headers_); auto captured_args = std::make_tuple(x...); diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 43ab2e5b53..0d968222e9 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -2,6 +2,8 @@ #if defined(USE_ARDUINO) && !defined(USE_ESP32) && !defined(USE_LIBRETINY) +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -22,8 +24,7 @@ static const char *const TAG = "http_request"; static constexpr int ESP8266_SSL_ERR_OOM = -1000; #endif -std::shared_ptr HttpRequestArduino::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestArduino::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -37,7 +38,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur const uint32_t start = millis(); - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; container->set_secure(secure); watchdog::WatchdogManager wdm(this->get_watchdog_timeout()); @@ -70,19 +71,19 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur stream_ptr = std::make_unique(); #endif // USE_HTTP_REQUEST_ESP8266_HTTPS - bool status = container->client_.begin(*stream_ptr, url.c_str()); + bool status = container->client_.begin(*stream_ptr, url); #elif defined(USE_RP2) if (secure) { container->client_.setInsecure(); } - bool status = container->client_.begin(url.c_str()); + bool status = container->client_.begin(url); #endif App.feed_wdt(); if (!status) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur container->client_.collectHeaders(header_keys, index); App.feed_wdt(); - container->status_code = container->client_.sendRequest(method.c_str(), body.c_str()); + container->status_code = container->client_.sendRequest(method, body.c_str()); App.feed_wdt(); if (container->status_code < 0) { #if defined(USE_ESP8266) && defined(USE_HTTP_REQUEST_ESP8266_HTTPS) @@ -139,7 +140,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur } #endif - ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url.c_str(), + ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url, HTTPClient::errorToString(container->status_code).c_str()); this->status_momentary_error("failed", 1000); @@ -147,7 +148,7 @@ std::shared_ptr HttpRequestArduino::perform(const std::string &ur return nullptr; } if (!is_success(container->status_code)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 028b9f44a1..62737f4d0d 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -54,7 +54,7 @@ class HttpRequestArduino final : public HttpRequestComponent { #endif protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; #ifdef USE_ESP8266 diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index cf231e20bd..a788970202 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -5,6 +5,8 @@ #include "httplib.h" #include "http_request_host.h" +#include + #include #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -16,8 +18,7 @@ namespace esphome::http_request { static const char *const TAG = "http_request"; -std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestHost::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -27,10 +28,10 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } std::regex url_regex(R"(^(([^:\/?#]+):)?(//([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?)", std::regex::extended); - std::smatch url_match_result; + std::cmatch url_match_result; if (!std::regex_match(url, url_match_result, url_regex) || url_match_result.length() < 7) { - ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Malformed URL: %s", url); return nullptr; } auto host = url_match_result[4].str(); @@ -54,7 +55,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, } httplib::Client client(scheme_host.c_str()); if (!client.is_valid()) { - ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url.c_str()); + ESP_LOGE(TAG, "HTTP Request failed; Invalid URL: %s", url); return nullptr; } client.set_follow_location(this->follow_redirects_); @@ -64,41 +65,41 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, #endif httplib::Result result; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { result = client.Get(path, h_headers, [&](const char *data, size_t data_length) { ESP_LOGV(TAG, "Got data length: %zu", data_length); container->response_body_.insert(container->response_body_.end(), (const uint8_t *) data, (const uint8_t *) data + data_length); return true; }); - } else if (method == "HEAD") { + } else if (strcmp(method, "HEAD") == 0) { result = client.Head(path, h_headers); - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { result = client.Put(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { result = client.Patch(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { result = client.Post(path, h_headers, body, ""); if (result) { auto data = std::vector(result->body.begin(), result->body.end()); container->response_body_.insert(container->response_body_.end(), data.begin(), data.end()); } } else { - ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method.c_str(), url.c_str()); + ESP_LOGW(TAG, "HTTP Request failed - unsupported method %s; URL: %s", method, url); container->end(); return nullptr; } App.feed_wdt(); if (!result) { - ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url.c_str(), (unsigned) result.error()); + ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url, (unsigned) result.error()); container->end(); this->status_momentary_error("failed", 1000); return nullptr; @@ -107,7 +108,7 @@ std::shared_ptr HttpRequestHost::perform(const std::string &url, auto response = *result; container->status_code = response.status; if (!is_success(response.status)) { - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), response.status); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, response.status); this->status_momentary_error("failed", 1000); // Still return the container, so it can be used to get the status code and error message } diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index 9045702f46..0ae9f2e27b 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -18,7 +18,7 @@ class HttpContainerHost : public HttpContainer { class HttpRequestHost final : public HttpRequestComponent { public: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 10313be89d..4e5a2c42b5 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -2,6 +2,8 @@ #ifdef USE_ESP32 +#include + #include "esphome/components/network/util.h" #include "esphome/components/watchdog/watchdog.h" @@ -48,8 +50,7 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { return ESP_OK; } -std::shared_ptr HttpRequestIDF::perform(const std::string &url, const std::string &method, - const std::string &body, +std::shared_ptr HttpRequestIDF::perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { @@ -59,15 +60,15 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } esp_http_client_method_t method_idf; - if (method == "GET") { + if (strcmp(method, "GET") == 0) { method_idf = HTTP_METHOD_GET; - } else if (method == "POST") { + } else if (strcmp(method, "POST") == 0) { method_idf = HTTP_METHOD_POST; - } else if (method == "PUT") { + } else if (strcmp(method, "PUT") == 0) { method_idf = HTTP_METHOD_PUT; - } else if (method == "DELETE") { + } else if (strcmp(method, "DELETE") == 0) { method_idf = HTTP_METHOD_DELETE; - } else if (method == "PATCH") { + } else if (strcmp(method, "PATCH") == 0) { method_idf = HTTP_METHOD_PATCH; } else { this->status_momentary_error("failed", ERROR_DURATION_MS); @@ -75,11 +76,11 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c return nullptr; } - bool secure = url.find("https:") != std::string::npos; + bool secure = strstr(url, "https:") != nullptr; esp_http_client_config_t config = {}; - config.url = url.c_str(); + config.url = url; config.method = method_idf; config.timeout_ms = this->timeout_; config.disable_auto_redirect = !this->follow_redirects_; @@ -218,7 +219,7 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c } } - ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url.c_str(), container->status_code); + ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); this->status_momentary_error("failed", ERROR_DURATION_MS); return container; } diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 16a5b6a161..f84dc9576b 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -30,6 +30,9 @@ class HttpContainerIDF : public HttpContainer { class HttpRequestIDF final : public HttpRequestComponent { public: + // User provided, not "= default": `new(p) HttpRequestIDF()` would zero-fill .bss that is already zero. + HttpRequestIDF() {} + void dump_config() override; void set_buffer_size_rx(uint16_t buffer_size_rx) { this->buffer_size_rx_ = buffer_size_rx; } @@ -38,7 +41,7 @@ class HttpRequestIDF final : public HttpRequestComponent { void set_ca_certificate(const char *ca_certificate) { this->ca_certificate_ = ca_certificate; } protected: - std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + std::shared_ptr perform(const char *url, const char *method, const std::string &body, const std::vector
&request_headers, const std::vector &lower_case_collect_headers) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 57dc86d55c..6a74c00e8e 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -1,5 +1,7 @@ #include "http_request_update.h" +#include + #include "esphome/core/application.h" #include "esphome/core/version.h" @@ -94,7 +96,7 @@ void HttpRequestUpdate::update_task(void *params) { auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { - ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_); if (container != nullptr) container->end(); result->error_str = LOG_STR("Failed to fetch manifest"); @@ -174,21 +176,26 @@ void HttpRequestUpdate::update_task(void *params) { allocator.deallocate(data, content_length); if (!valid) { - ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_); result->error_str = LOG_STR("Failed to parse manifest JSON"); goto defer; // NOLINT(cppcoreguidelines-avoid-goto) } // Merge source_url_ and firmware_url if (!info->firmware_url.empty() && info->firmware_url.find("http") == std::string::npos) { - std::string path = info->firmware_url; - if (path[0] == '/') { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.find('/', 8)); - info->firmware_url = domain + path; + const char *source = this_update->source_url_; + const size_t source_len = strlen(source); + size_t prefix_len; + if (info->firmware_url[0] == '/') { + // scheme and host, up to the first slash after "https://" + const char *host_end = source_len > 8 ? strchr(source + 8, '/') : nullptr; + prefix_len = host_end != nullptr ? host_end - source : source_len; } else { - std::string domain = this_update->source_url_.substr(0, this_update->source_url_.rfind('/') + 1); - info->firmware_url = domain + path; + // directory of the manifest, up to and including its last slash + const char *dir_end = strrchr(source, '/'); + prefix_len = dir_end != nullptr ? dir_end - source + 1 : 0; } + info->firmware_url.insert(0, source, prefix_len); } #ifdef ESPHOME_PROJECT_VERSION diff --git a/esphome/components/http_request/update/http_request_update.h b/esphome/components/http_request/update/http_request_update.h index be9fbf72bf..05a741b6cd 100644 --- a/esphome/components/http_request/update/http_request_update.h +++ b/esphome/components/http_request/update/http_request_update.h @@ -21,7 +21,7 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo void perform(bool force) override; void check() override { this->update(); } - void set_source_url(const std::string &source_url) { this->source_url_ = source_url; } + void set_source_url(const char *source_url) { this->source_url_ = source_url; } void set_request_parent(HttpRequestComponent *request_parent) { this->request_parent_ = request_parent; } void set_ota_parent(OtaHttpRequestComponent *ota_parent) { this->ota_parent_ = ota_parent; } @@ -33,13 +33,15 @@ class HttpRequestUpdate final : public update::UpdateEntity, public PollingCompo protected: HttpRequestComponent *request_parent_; OtaHttpRequestComponent *ota_parent_; - std::string source_url_; static void update_task(void *params); #ifdef USE_ESP32 TaskHandle_t update_task_handle_{nullptr}; #endif uint8_t initial_check_remaining_{0}; + + private: + const char *source_url_{nullptr}; // literal from codegen }; } // namespace esphome::http_request diff --git a/esphome/components/htu21d/htu21d.h b/esphome/components/htu21d/htu21d.h index f86d62c5e8..c4babfd797 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 86dca77725..d78267964b 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 8960759d9b..41cc54686a 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 3522acf049..08d068d0f0 100644 --- a/esphome/components/hub75/display.py +++ b/esphome/components/hub75/display.py @@ -18,8 +18,7 @@ from esphome.const import ( CONF_ROTATION, CONF_UPDATE_INTERVAL, ) -from esphome.core import ID, EnumValue -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import EnumValue import esphome.final_validate as fv from esphome.helpers import add_class_to_obj from esphome.types import ConfigType @@ -167,7 +166,6 @@ ROTATIONS = { HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) Hub75Config = cg.global_ns.struct("Hub75Config") Hub75Pins = cg.global_ns.struct("Hub75Pins") -SetBrightnessAction = hub75_ns.class_("SetBrightnessAction", automation.Action) def _merge_board_pins(config: ConfigType) -> ConfigType: @@ -619,9 +617,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_writer(lambda_)) -@automation.register_action( +automation.register_apply_action( "hub75.set_brightness", - SetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(HUB75Display), @@ -629,16 +626,5 @@ async def to_code(config: ConfigType) -> None: }, key=CONF_BRIGHTNESS, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.uint8), ) -async def hub75_set_brightness_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.uint8) - cg.add(var.set_brightness(template_)) - return var diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp index ba652d427d..d36928a83a 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -1,5 +1,4 @@ #include "hub75_component.h" -#include "esphome/core/application.h" #include @@ -124,11 +123,11 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]] return; - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; driver_->set_pixel(x, y, color.r, color.g, color.b); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 98bc2e52e6..dbadc56c9d 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 3f006a65fe..1fe5d9e14a 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 37895ac4e7..1b17380ed8 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_spdif.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp index ed5145d4b0..ec4e459be7 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -48,10 +48,11 @@ static esp_err_t spdif_write_cb(void *user_ctx, uint32_t *data, size_t size, Tic auto *speaker = static_cast(user_ctx); size_t bytes_written = 0; esp_err_t err = i2s_channel_write(speaker->get_tx_handle(), data, size, &bytes_written, ticks_to_wait); - if (err != ESP_OK) { + if (err != ESP_OK || bytes_written != size) { ESP_LOGV(TAG, "I2S write failed: %s (wrote %zu/%zu bytes)", esp_err_to_name(err), bytes_written, size); + return (err != ESP_OK) ? err : ESP_FAIL; } - return err; + return ESP_OK; } void I2SAudioSpeakerSPDIF::setup() { @@ -167,33 +168,44 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } - if (!successful_setup) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } else { - // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. - // This ensures the first data transmitted is valid SPDIF (not raw zeros from - // auto_clear) and prevents phantom DMA events before real audio is available. - // Each preloaded block pushes a 0-real-frame record so that the corresponding - // on_sent events drain in lockstep without crediting any audio frames. + // Preload DMA buffers with SPDIF-encoded silence before enabling the channel. + // This ensures the first data transmitted is valid SPDIF (not raw zeros from + // auto_clear) and prevents phantom DMA events before real audio is available. + // Each preloaded block pushes a 0-real-frame record so that the corresponding + // on_sent events drain in lockstep without crediting any audio frames. Runs with + // the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { + bool ok = true; this->spdif_encoder_->set_preload_mode(true); for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) { // i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait. - esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0); - if (preload_err != ESP_OK) { - break; // DMA preload buffer full or error - } const uint32_t silence_record = 0; - xQueueSendToBack(this->write_records_queue_, &silence_record, 0); + if ((this->spdif_encoder_->flush_with_silence(0) != ESP_OK) || + (xQueueSendToBack(this->write_records_queue_, &silence_record, 0) != pdTRUE)) { + ok = false; + break; + } } this->spdif_encoder_->set_preload_mode(false); this->spdif_encoder_->reset(); // Clean encoder state for the main loop + return ok; + }; - // Now register the callback and enable the channel + if (successful_setup) { + successful_setup = preload_silence(); + } + + if (successful_setup) { + // Register the callback before enabling so the first transmitted block generates a queued event. xQueueReset(this->i2s_event_queue_); const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb}; i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this); - i2s_channel_enable(this->tx_handle_); + successful_setup = i2s_channel_enable(this->tx_handle_) == ESP_OK; + } + if (!successful_setup) { + xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); + } else { // Always-fill model: each iteration produces exactly one SPDIF block (= one DMA buffer). // We drain real PCM up to one block from the ring buffer and silence-pad any remainder. // Blocking writes pace the loop at the DMA consumption rate. This mirrors the standard @@ -210,24 +222,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { uint32_t spdif_pending_frames = 0; int64_t spdif_pending_timestamp = 0; uint32_t spdif_dma_event_count = 0; + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); // SPDIF continuous mode: loop runs indefinitely, outputting silence when no audio data // to keep the receiver synced. Exits only via break (stream info change, silence timeout, - // lockstep desync, dropped event, or partial-write failure). + // or a failed lockstep resync). while (true) { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); - // The ISR pairs COMMAND_STOP with ERR_DROPPED_EVENT when it has to discard a completion - // event; that desyncs the lockstep queues permanently and the only safe recovery is a full - // task restart. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGV(TAG, "Exiting: ISR dropped event, restarting to recover lockstep"); - break; - } // User-initiated stop. In SPDIF continuous mode, transition to silence output rather // than tearing the task down. this->spdif_silence_start_ = millis(); @@ -244,6 +252,30 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place. Frames held back by decimation are credited too, since their + // blocks are discarded with the rest of the DMA contents. + this->spdif_encoder_->reset(); + const uint32_t credited_frames = unrecorded_frames + spdif_pending_frames; + const bool resynced = this->resync_lockstep_(credited_frames, preload_silence); + unrecorded_frames = 0; + spdif_pending_frames = 0; + spdif_dma_event_count = 0; + resync_needed = false; + if (credited_frames > 0) { + // Real audio was dropped, so the silence timer's start no longer reflects the stream + this->spdif_silence_start_ = 0; + } + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR completion events, popping a matching record for each. int64_t write_timestamp; bool lockstep_broken = false; @@ -253,8 +285,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // order matches DMA completion order. Empty records queue here means lockstep broke. uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -290,8 +321,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } } if (lockstep_broken) { - ESP_LOGV(TAG, "Exiting: lockstep desync, restarting task"); - break; + resync_needed = true; + continue; } // Always-fill: produce exactly one SPDIF block this iteration. The blocking encoder write @@ -322,9 +353,8 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { &blocks_sent, &pcm_consumed); if (err != ESP_OK) { // A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer; - // resuming would credit the next iteration's bytes against an old block. Bail and - // let loop() restart the task with a clean encoder. - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // resuming would credit the next iteration's bytes against an old block. + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); partial_write_failure = true; break; } @@ -341,7 +371,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } if (!block_committed) { @@ -349,16 +381,20 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() { // or emit a full silence block if the encoder is empty. esp_err_t err = this->spdif_encoder_->flush_with_silence(write_timeout_ticks); if (err != ESP_OK) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "SPDIF block send failed, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } } // One block committed to DMA; push exactly one record carrying its real-audio frame count. // Failure here means the records queue is full, which violates the lockstep invariant. if (xQueueSendToBack(this->write_records_queue_, &real_frames_in_block, 0) != pdTRUE) { - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_in_block; + resync_needed = true; + continue; } // Silence-timeout tracking and graceful-stop reset. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 9feaf39fff..4b74b2e29d 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -14,17 +14,19 @@ #include "esp_timer.h" -// esp-audio-libs -#include +#include namespace esphome::i2s_audio { static const char *const TAG = "i2s_audio.speaker"; -// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor. -// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB. +// Software volume control maps the user-facing (0.0, 1.0) range linearly to a dB reduction in +// [-49.0, 0.0] dB; 0.0 is silence. static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f; +// Rate at which the software gain moves toward a new target. +static constexpr uint32_t GAIN_RAMP_MS_PER_DB = 1; + void I2SAudioSpeakerBase::setup() { this->event_group_ = xEventGroupCreate(); @@ -34,9 +36,20 @@ void I2SAudioSpeakerBase::setup() { return; } - // Initialize volume control. When audio_dac is configured, this sets the DAC volume. + // Initialize volume control. When audio_dac is configured, this sets the DAC volume and mute state. // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); + this->set_mute_state(this->mute_state_); + + // 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() { @@ -74,20 +87,15 @@ 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"); - // Lockstep-breaking error bits are latched by the task and cleared along with all other bits - // when TASK_STOPPED is processed; log them here, exactly once, as the task winds down. - if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { - ESP_LOGE(TAG, "ISR event queue overflow, restarting speaker task to recover timestamp sync"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_PARTIAL_WRITE) { - ESP_LOGE(TAG, "Partial DMA write broke buffer alignment, restarting speaker task"); - } - if (event_group_bits & SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC) { - ESP_LOGE(TAG, "Event/record queues desynced, restarting speaker task"); - } xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING); this->state_ = speaker::STATE_STOPPING; } @@ -136,6 +144,10 @@ void I2SAudioSpeakerBase::loop() { break; } + // Seed the ramp at the live target so this run adopts it instantly rather than fading to it + // from wherever the previous run left off. Posted here, not in the task: the ramp's mailbox + // allows one writer, and that is the main loop. + this->post_software_gain_(0); xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY, &this->speaker_task_handle_); @@ -153,50 +165,31 @@ void I2SAudioSpeakerBase::loop() { } void I2SAudioSpeakerBase::set_volume(float volume) { - this->volume_ = volume; -#ifdef USE_AUDIO_DAC - if (this->audio_dac_ != nullptr) { - if (volume > 0.0f) { - this->audio_dac_->set_mute_off(); - } - this->audio_dac_->set_volume(volume); - } else -#endif // USE_AUDIO_DAC - { - // Fallback to software volume control by using a Q31 fixed point scaling factor. - // At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely - // and avoid any floating-point precision issues that could cause slight volume reduction. - if (volume >= 1.0f) { - this->q31_volume_factor_ = INT32_MAX; - } else if (volume <= 0.0f) { - this->q31_volume_factor_ = 0; - } else { - this->q31_volume_factor_ = - esp_audio_libs::gain::db_to_q31(remap(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f)); - } - } + speaker::Speaker::set_volume(volume); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); } void I2SAudioSpeakerBase::set_mute_state(bool mute_state) { - this->mute_state_ = mute_state; + speaker::Speaker::set_mute_state(mute_state); + this->post_software_gain_(this->audio_stream_info_.ms_to_samples(GAIN_RAMP_MS_PER_DB)); +} + +void I2SAudioSpeakerBase::post_software_gain_(uint32_t rate_samples) { #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } else -#endif // USE_AUDIO_DAC - { - if (mute_state) { - // Fallback to software volume control and scale by 0 - this->q31_volume_factor_ = 0; - } else { - // Revert to previous volume when unmuting - this->set_volume(this->volume_); - } + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp stays at unity } +#endif // USE_AUDIO_DAC + // Software volume control. The ramp treats 0 dB as unity and skips processing there. + float target_db; + if (this->is_silent_()) { + target_db = -INFINITY; + } else if (this->volume_ >= 1.0f) { + target_db = 0.0f; + } else { + target_db = remap(this->volume_, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f); + } + this->gain_ramp_.set_target_db_at_rate(target_db, rate_samples); } size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t ticks_to_wait) { @@ -320,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(); } @@ -337,16 +328,10 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s I2SAudioSpeakerBase *this_speaker = (I2SAudioSpeakerBase *) user_ctx; if (xQueueIsQueueFullFromISR(this_speaker->i2s_event_queue_)) { - // Queue is full, so discard the oldest event. Once we drop a completion event, ``i2s_event_queue_`` - // and any per-buffer record queue maintained by the task are permanently desynced, so the task - // must restart to recover. Set both ERR_DROPPED_EVENT (so loop() can log it) and COMMAND_STOP - // (so the task bails immediately, closing the race where loop() could clear the error bit - // before the task observes it). + // Queue is full, so discard the oldest event. The lockstep queues are now desynced; the task resyncs them. int64_t dummy; xQueueReceiveFromISR(this_speaker->i2s_event_queue_, &dummy, &need_yield1); - xEventGroupSetBitsFromISR(this_speaker->event_group_, - SpeakerEventGroupBits::ERR_DROPPED_EVENT | SpeakerEventGroupBits::COMMAND_STOP, - &need_yield2); + xEventGroupSetBitsFromISR(this_speaker->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT, &need_yield2); } xQueueSendToBackFromISR(this_speaker->i2s_event_queue_, &now, &need_yield3); @@ -354,15 +339,33 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s return need_yield1 | need_yield2 | need_yield3; } -void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { - if (this->q31_volume_factor_ == INT32_MAX) { - return; // Max volume, no processing needed +void I2SAudioSpeakerBase::drain_lockstep_(uint32_t extra_frames) { + // Stop DMA so no more completion events arrive while the queues are rebuilt + i2s_channel_disable(this->tx_handle_); + xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_DROPPED_EVENT); + + uint32_t frames = extra_frames; + uint32_t record_frames = 0; + while (xQueueReceive(this->write_records_queue_, &record_frames, 0) == pdTRUE) { + frames += record_frames; } + xQueueReset(this->i2s_event_queue_); + if (frames > 0) { + ESP_LOGV(TAG, "Crediting %" PRIu32 " dropped frames as played", frames); + this->audio_output_callback_(frames, esp_timer_get_time()); + } +} + +void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) { +#ifdef USE_AUDIO_DAC + if (this->audio_dac_ != nullptr) { + return; // Hardware volume; the ramp is never targeted + } +#endif // USE_AUDIO_DAC const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1); - const uint32_t len = bytes_read / bytes_per_sample; - - esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample); + this->gain_ramp_.process(data, static_cast(bytes_per_sample), + this->current_stream_info_.bytes_to_samples(bytes_read)); } void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) { diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index adb6ca5e3f..965eede020 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -16,6 +16,8 @@ #include "esphome/core/gpio.h" #include "esphome/core/helpers.h" +#include // esp-audio-libs + namespace esphome::i2s_audio { // Shared constants used by both standard and SPDIF speaker implementations @@ -34,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t { ERR_ESP_NO_MEM = (1 << 19), - ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event - ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested - ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync + ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits }; @@ -77,19 +77,23 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool has_buffered_data() const override; - /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Sets the volume of the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to convert the volume to a dB target for + /// the gain ramp. /// @param volume between 0.0 and 1.0 void set_volume(float volume) override; - /// @brief Mutes or unmute the speaker. Uses the speaker's configured audio dac component. If unavailble, it is - /// implemented as a software volume control. Overrides the default setter to convert the floating point volume to a - /// Q15 fixed-point factor. + /// @brief Mutes or unmutes the speaker. Uses the speaker's configured audio dac component. If unavailable, it is + /// implemented as a software volume control. Overrides the default setter to post the mute state to the gain ramp. /// @param mute_state true for muting, false for unmuting void set_mute_state(bool mute_state) override; protected: + /// @brief Posts the ramp target derived from the current volume and mute state. No-op when an audio dac owns + /// volume. Main loop only. + /// @param rate_samples Samples the ramp takes per dB of change; 0 adopts the target at once + void post_software_gain_(uint32_t rate_samples); + /// @brief FreeRTOS task entry point. Casts params to I2SAudioSpeakerBase and calls run_speaker_task_(). /// @param params I2SAudioSpeakerBase component pointer static void speaker_task(void *params); @@ -125,10 +129,29 @@ 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() {} - /// @brief Apply software volume control using Q15 fixed-point scaling. + /// @brief Rebuilds the lockstep queues in place: disables the channel, credits every in-flight real frame as + /// played now, empties both queues, preloads silence through ``preload`` and re-enables the channel. Speaker + /// task only. + /// @param extra_frames Real frames the caller consumed that never reached a write record + /// @param preload Callable returning true once every DMA descriptor holds silence with a matching record + /// @return false if the preload or the channel enable failed; the caller should restart the task + template bool resync_lockstep_(uint32_t extra_frames, F &&preload) { + this->drain_lockstep_(extra_frames); + return preload() && (i2s_channel_enable(this->tx_handle_) == ESP_OK); + } + + /// @brief Disables the channel, credits ``extra_frames`` plus every real frame still recorded as in flight, + /// and empties both lockstep queues. + void drain_lockstep_(uint32_t extra_frames); + + /// @brief Apply software volume control by running the samples through the gain ramp. Called from the + /// speaker task only. /// @param data Pointer to audio sample data (modified in place) /// @param bytes_read Number of bytes of audio data void apply_software_volume_(uint8_t *data, size_t bytes_read); @@ -155,7 +178,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public bool pause_state_{false}; - int32_t q31_volume_factor_{INT32_MAX}; + // Smooths software gain changes. The main loop posts targets, the speaker task processes; + // GainRamp's mailbox makes that safe. The main loop is the only poster. + esp_audio_libs::gain::GainRamp gain_ramp_; audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input) // Format actually clocked out of the I2S peripheral. Same channel count and sample rate as diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index 17c93763d6..b4b6173458 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -134,27 +134,29 @@ void I2SAudioSpeaker::run_speaker_task() { } } - if (successful_setup) { - // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer. - // This guarantees that every on_sent event has a corresponding write record from the start, so - // ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime. + // Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer, so every + // on_sent event has a write record from the start. Runs with the channel disabled: at startup and after a resync. + auto preload_silence = [&]() -> bool { for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) { size_t bytes_loaded = 0; esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded); if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) { ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err, (unsigned) bytes_loaded); - successful_setup = false; - break; + return false; } uint32_t zero_real_frames = 0; if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) { // Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries. ESP_LOGV(TAG, "Failed to push preload write record"); - successful_setup = false; - break; + return false; } } + return true; + }; + + if (successful_setup) { + successful_setup = preload_silence(); } if (successful_setup) { @@ -177,6 +179,9 @@ void I2SAudioSpeaker::run_speaker_task() { // stop to wait until every real-audio buffer has been confirmed played by an ISR event. uint32_t pending_real_buffers = 0; uint32_t last_data_received_time = millis(); + bool resync_needed = false; + // Real frames consumed from the ring buffer that never reached a write record + uint32_t unrecorded_frames = 0; xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); @@ -197,8 +202,6 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t event_group_bits = xEventGroupGetBits(this->event_group_); if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) { - // COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion - // event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases). xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP); ESP_LOGV(TAG, "Exiting: COMMAND_STOP received"); break; @@ -214,6 +217,22 @@ void I2SAudioSpeaker::run_speaker_task() { break; } + if (event_group_bits & SpeakerEventGroupBits::ERR_DROPPED_EVENT) { + ESP_LOGE(TAG, "ISR event queue overflow, resyncing DMA lockstep"); + resync_needed = true; + } + if (resync_needed) { + // Rebuild the lockstep in place; the ring buffer keeps accepting audio throughout + const bool resynced = this->resync_lockstep_(unrecorded_frames, preload_silence); + unrecorded_frames = 0; + pending_real_buffers = 0; + resync_needed = false; + if (!resynced) { + ESP_LOGE(TAG, "DMA lockstep resync failed, restarting speaker task"); + break; + } + } + // Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_ // entry by construction (preloaded records at startup, plus exactly one record pushed per // iteration alongside exactly one DMA-buffer-sized write). @@ -223,8 +242,7 @@ void I2SAudioSpeaker::run_speaker_task() { uint32_t real_frames = 0; if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) { // Should never happen: would indicate the lockstep invariant is broken. - ESP_LOGV(TAG, "Event without matching write record"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); + ESP_LOGE(TAG, "Event without matching write record, resyncing DMA lockstep"); lockstep_broken = true; break; } @@ -240,7 +258,8 @@ void I2SAudioSpeaker::run_speaker_task() { } } if (lockstep_broken) { - break; + resync_needed = true; + continue; } // Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring @@ -299,10 +318,12 @@ void I2SAudioSpeaker::run_speaker_task() { size_t bw = 0; i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != output_bytes) { - // A short real-audio write breaks DMA descriptor alignment for every subsequent event; - // the only safe recovery is to restart the task. - ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) output_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); + // A short write breaks DMA descriptor alignment for every subsequent event. Drop the chunk rather + // than retry it: it was already narrowed in place. + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) output_bytes); + audio_source->consume(input_bytes); + real_frames_total += frames_to_write; partial_write_failure = true; break; } @@ -316,7 +337,9 @@ void I2SAudioSpeaker::run_speaker_task() { } if (partial_write_failure) { - break; + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } const size_t silence_bytes = dma_buffer_bytes - bytes_written_total; @@ -325,19 +348,22 @@ void I2SAudioSpeaker::run_speaker_task() { i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS); if (bw != silence_bytes) { // Same descriptor-alignment hazard as a partial real-audio write. - ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE); - break; + ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw, + (unsigned) silence_bytes); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } } // Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this // succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep - // invariant is broken and every subsequent timestamp would be silently wrong, so bail. + // invariant is broken and every subsequent timestamp would be silently wrong, so rebuild it. if (xQueueSend(this->write_records_queue_, &real_frames_total, 0) != pdTRUE) { - ESP_LOGV(TAG, "Exiting: write records queue full"); - xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC); - break; + ESP_LOGE(TAG, "Write records queue full, resyncing DMA lockstep"); + unrecorded_frames += real_frames_total; + resync_needed = true; + continue; } if (real_frames_total > 0) { pending_real_buffers++; diff --git a/esphome/components/iaqcore/sensor.py b/esphome/components/iaqcore/sensor.py index 1b905e4c63..daee46f083 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/icnt86/__init__.py b/esphome/components/icnt86/__init__.py new file mode 100644 index 0000000000..07f3b4e31c --- /dev/null +++ b/esphome/components/icnt86/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@danepowell"] diff --git a/esphome/components/icnt86/icnt86.cpp b/esphome/components/icnt86/icnt86.cpp new file mode 100644 index 0000000000..62a4586ebc --- /dev/null +++ b/esphome/components/icnt86/icnt86.cpp @@ -0,0 +1,84 @@ +#include "icnt86.h" +#include "esphome/core/log.h" + +namespace esphome::icnt86 { + +static const char *const TAG = "icnt86"; +static constexpr uint16_t REG_TOUCH_NUM = 0x1001; +static constexpr uint16_t REG_POINT1 = 0x1002; +static constexpr uint8_t MAX_TOUCHES = 5; +static constexpr uint8_t POINT_SIZE = 7; + +void ICNT86Touchscreen::setup() { + ESP_LOGCONFIG(TAG, "Setting up icnt86 Touchscreen..."); + + // Register interrupt pin + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + + // Perform reset if necessary + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + delay(10); + this->reset_pin_->digital_write(true); + } + + if (this->x_raw_max_ == this->x_raw_min_) { + this->x_raw_max_ = this->display_->get_native_width(); + } + if (this->y_raw_max_ == this->y_raw_min_) { + this->y_raw_max_ = this->display_->get_native_height(); + } +} + +void ICNT86Touchscreen::update_touches() { + uint8_t buf[MAX_TOUCHES * POINT_SIZE] = {0}; + uint8_t mask[1] = {0x00}; + + if (this->read_register16(REG_TOUCH_NUM, buf, 1) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch count"); + return; + } + uint8_t touch_count = buf[0]; + + if (touch_count == 0x00 || touch_count > MAX_TOUCHES) { // No new touch + this->status_clear_warning(); + return; + } + if (this->read_register16(REG_POINT1, buf, touch_count * POINT_SIZE) != i2c::ERROR_OK) { + this->status_set_warning(); + this->skip_update_ = true; + ESP_LOGW(TAG, "Failed to read touch points"); + return; + } + this->write_register16(REG_TOUCH_NUM, mask, 1); + ESP_LOGV(TAG, "Touch count: %d", touch_count); + this->status_clear_warning(); + + for (uint8_t i = 0; i < touch_count; i++) { + uint16_t x = ((uint16_t) buf[2 + 7 * i] << 8) + buf[1 + 7 * i]; + uint16_t y = ((uint16_t) buf[4 + 7 * i] << 8) + buf[3 + 7 * i]; + uint8_t pressure = buf[5 + 7 * i]; + uint8_t touch_id = buf[6 + 7 * i]; + + // A zero-pressure report just means this point is no longer touched; skipping it here leaves is_touched_ + // false (when no other point is active) so send_touches_() reports the release as normal. + if (pressure != 0) { + this->add_raw_touch_position_(touch_id, x, y, pressure); + } + } +} + +void ICNT86Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, "icnt86 Touchscreen:"); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/icnt86.h b/esphome/components/icnt86/icnt86.h new file mode 100644 index 0000000000..0d96b01524 --- /dev/null +++ b/esphome/components/icnt86/icnt86.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" + +namespace esphome::icnt86 { + +class ICNT86Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + void set_reset_pin(GPIOPin *pin) { this->reset_pin_ = pin; } + + protected: + void update_touches() override; + InternalGPIOPin *interrupt_pin_{}; + GPIOPin *reset_pin_{nullptr}; +}; + +} // namespace esphome::icnt86 diff --git a/esphome/components/icnt86/touchscreen.py b/esphome/components/icnt86/touchscreen.py new file mode 100644 index 0000000000..5d7a738612 --- /dev/null +++ b/esphome/components/icnt86/touchscreen.py @@ -0,0 +1,40 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_RESET_PIN +from esphome.types import ConfigType + +CODEOWNERS = ["@danepowell"] +DEPENDENCIES = ["i2c"] + +icnt86_ns = cg.esphome_ns.namespace("icnt86") +ICNT86Touchscreen = icnt86_ns.class_( + "ICNT86Touchscreen", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = touchscreen.touchscreen_schema("250ms").extend( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ICNT86Touchscreen), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ).extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await touchscreen.register_touchscreen(var, config) + await i2c.register_i2c_device(var, config) + + if interrupt_pin_config := config.get(CONF_INTERRUPT_PIN): + cg.add( + var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin_config)) + ) + + if reset_pin_config := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin_config))) diff --git a/esphome/components/image/image.cpp b/esphome/components/image/image.cpp index 9b603683ab..bfe311be28 100644 --- a/esphome/components/image/image.cpp +++ b/esphome/components/image/image.cpp @@ -48,14 +48,12 @@ void Image::draw(int x, int y, display::Display *display, Color color_on, Color continue; // skip drawing } break; - case TRANSPARENCY_ALPHA_CHANNEL: { - auto on = (float) gray / 255.0f; - auto off = 1.0f - on; - // blend color_on and color_off - color = Color(color_on.r * on + color_off.r * off, color_on.g * on + color_off.g * off, - color_on.b * on + color_off.b * off, 0xFF); + case TRANSPARENCY_ALPHA_CHANNEL: + // gray is the alpha: blend from color_off to color_on, drawn opaque + color = Color(Color::blend_channel(color_off.r, color_on.r, gray), + Color::blend_channel(color_off.g, color_on.g, gray), + Color::blend_channel(color_off.b, color_on.b, gray), 0xFF); break; - } default: break; } diff --git a/esphome/components/image/image.h b/esphome/components/image/image.h index ccc2f23f20..fd9e92c21d 100644 --- a/esphome/components/image/image.h +++ b/esphome/components/image/image.h @@ -54,7 +54,6 @@ class Image : public display::BaseImage { const uint8_t *data_start_; Transparency transparency_; size_t bpp_{}; - size_t stride_{}; #ifdef USE_LVGL lv_img_dsc_t dsc_{}; #endif diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index 412d143a48..9b57b6561f 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -38,9 +38,11 @@ def _process_next_url(url: str) -> str: return url -async def setup_improv_core(var: MockObj, config: ConfigType, component: str) -> None: +async def setup_improv_core(var: MockObj, config: ConfigType) -> None: if next_url := config.get(CONF_NEXT_URL): cg.add(var.set_next_url(_process_next_url(next_url))) - cg.add_define(f"USE_{component.upper()}_NEXT_URL") + # One define for all transports: next_url_ is per object, so a transport + # configured without next_url: calls add_next_url_ and appends nothing. + cg.add_define("USE_IMPROV_NEXT_URL") cg.add_library("improv/Improv", "1.2.7") diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index 1babeb5b5a..6745f8064b 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -8,7 +8,7 @@ namespace esphome::improv_base { -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL static const char *const TAG = "improv_base"; static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; diff --git a/esphome/components/improv_base/improv_base.h b/esphome/components/improv_base/improv_base.h index 352bb75d5f..97801302d4 100644 --- a/esphome/components/improv_base/improv_base.h +++ b/esphome/components/improv_base/improv_base.h @@ -3,7 +3,7 @@ #include #include "esphome/core/defines.h" -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL #include #endif @@ -11,12 +11,12 @@ namespace esphome::improv_base { class ImprovBase { public: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL void set_next_url(const char *next_url) { this->next_url_ = next_url; } #endif protected: -#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL) +#ifdef USE_IMPROV_NEXT_URL /// Format next_url_ into buffer, replacing placeholders. Returns length written. size_t get_formatted_next_url_(char *buffer, size_t buffer_size); /// Append the formatted next_url to the RPC response, warning if it does not fit. diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/improv_ble/__init__.py similarity index 63% rename from esphome/components/esp32_improv/__init__.py rename to esphome/components/improv_ble/__init__.py index 32eb166014..72ac586628 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,14 +1,41 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import binary_sensor, esp32_ble, improv_base, output -from esphome.components.esp32_ble import BTLoggers +from esphome.components import binary_sensor, improv_base, output import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, CONF_TRIGGER_ID +from esphome.const import ( + CONF_ID, + CONF_ON_START, + CONF_ON_STATE, + CONF_TRIGGER_ID, + PLATFORM_ESP32, +) +from esphome.core import CORE from esphome.types import ConfigType -AUTO_LOAD = ["esp32_ble_server", "improv_base"] +# The BLE GATT server component that hosts the Improv service, per target +# platform. improv_ble itself is platform neutral; supporting another chip +# means adding its BLE server component here and the matching backend in +# improv_ble_component.cpp. Doubles as the platform gate below, so an +# unsupported chip is rejected in validation rather than at link time. +BLE_SERVER_BACKENDS: dict[str, str] = { + PLATFORM_ESP32: "esp32_ble_server", +} + + +def AUTO_LOAD() -> list[str]: + auto_load = ["improv_base"] + if backend := BLE_SERVER_BACKENDS.get(CORE.target_platform): + auto_load.append(backend) + return auto_load + + CODEOWNERS = ["@jesserockz"] -DEPENDENCIES = ["wifi", "esp32"] +DEPENDENCIES = ["wifi"] + +# Legacy top-level YAML key that routes here; esphome/loader.py and +# esphome/config.py handle the warning and the key rename. +ALIASES = ["esp32_improv"] +ALIAS_REMOVAL_VERSION = "2027.4.0" CONF_AUTHORIZED_DURATION = "authorized_duration" CONF_AUTHORIZER = "authorizer" @@ -29,29 +56,29 @@ improv_ns = cg.esphome_ns.namespace("improv") Error = improv_ns.enum("Error") State = improv_ns.enum("State") -esp32_improv_ns = cg.esphome_ns.namespace("esp32_improv") -ESP32ImprovComponent = esp32_improv_ns.class_("ESP32ImprovComponent", cg.Component) -ESP32ImprovProvisionedTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisionedTrigger", automation.Trigger.template() +improv_ble_ns = cg.esphome_ns.namespace("improv_ble") +ImprovBLEComponent = improv_ble_ns.class_("ImprovBLEComponent", cg.Component) +ImprovBLEProvisionedTrigger = improv_ble_ns.class_( + "ImprovBLEProvisionedTrigger", automation.Trigger.template() ) -ESP32ImprovProvisioningTrigger = esp32_improv_ns.class_( - "ESP32ImprovProvisioningTrigger", automation.Trigger.template() +ImprovBLEProvisioningTrigger = improv_ble_ns.class_( + "ImprovBLEProvisioningTrigger", automation.Trigger.template() ) -ESP32ImprovStartTrigger = esp32_improv_ns.class_( - "ESP32ImprovStartTrigger", automation.Trigger.template() +ImprovBLEStartTrigger = improv_ble_ns.class_( + "ImprovBLEStartTrigger", automation.Trigger.template() ) -ESP32ImprovStateTrigger = esp32_improv_ns.class_( - "ESP32ImprovStateTrigger", automation.Trigger.template() +ImprovBLEStateTrigger = improv_ble_ns.class_( + "ImprovBLEStateTrigger", automation.Trigger.template() ) -ESP32ImprovStoppedTrigger = esp32_improv_ns.class_( - "ESP32ImprovStoppedTrigger", automation.Trigger.template() +ImprovBLEStoppedTrigger = improv_ble_ns.class_( + "ImprovBLEStoppedTrigger", automation.Trigger.template() ) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(ESP32ImprovComponent), + cv.GenerateID(): cv.declare_id(ImprovBLEComponent), cv.Required(CONF_AUTHORIZER): cv.Any( cv.none, cv.use_id(binary_sensor.BinarySensor) ), @@ -68,55 +95,60 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisionedTrigger + ImprovBLEProvisionedTrigger ), } ), cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovProvisioningTrigger + ImprovBLEProvisioningTrigger ), } ), cv.Optional(CONF_ON_START): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStartTrigger + ImprovBLEStartTrigger ), } ), cv.Optional(CONF_ON_STATE): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStateTrigger + ImprovBLEStateTrigger ), } ), cv.Optional(CONF_ON_STOP): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ESP32ImprovStoppedTrigger + ImprovBLEStoppedTrigger ), } ), } ) .extend(improv_base.IMPROV_SCHEMA) - .extend(cv.COMPONENT_SCHEMA) + .extend(cv.COMPONENT_SCHEMA), + cv.only_on(list(BLE_SERVER_BACKENDS)), ) async def to_code(config: ConfigType) -> None: + # ESP32 backend setup: the platform gate above means this is the only backend + # that can reach to_code. Make it conditional when a second one is added. + from esphome.components import esp32_ble + # Register the loggers this component needs - esp32_ble.register_bt_logger(BTLoggers.GATT, BTLoggers.SMP) + esp32_ble.register_bt_logger(esp32_ble.BTLoggers.GATT, esp32_ble.BTLoggers.SMP) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - cg.add_define("USE_IMPROV") + cg.add_define("USE_IMPROV_BLE") - await improv_base.setup_improv_core(var, config, "esp32_improv") + await improv_base.setup_improv_core(var, config) cg.add(var.set_identify_duration(config[CONF_IDENTIFY_DURATION])) cg.add(var.set_authorized_duration(config[CONF_AUTHORIZED_DURATION])) @@ -155,4 +187,4 @@ async def to_code(config: ConfigType) -> None: await automation.build_automation(trigger, [], conf) use_state_callback = True if use_state_callback: - cg.add_define("USE_ESP32_IMPROV_STATE_CALLBACK") + cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") diff --git a/esphome/components/esp32_improv/automation.h b/esphome/components/improv_ble/automation.h similarity index 55% rename from esphome/components/esp32_improv/automation.h rename to esphome/components/improv_ble/automation.h index b3b61f4778..223a129238 100644 --- a/esphome/components/esp32_improv/automation.h +++ b/esphome/components/improv_ble/automation.h @@ -1,17 +1,17 @@ #pragma once #ifdef USE_ESP32 -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK -#include "esp32_improv_component.h" +#ifdef USE_IMPROV_BLE_STATE_CALLBACK +#include "improv_ble_component.h" #include "esphome/core/automation.h" #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { -class ESP32ImprovProvisionedTrigger final : public Trigger<> { +class ImprovBLEProvisionedTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisionedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisionedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONED && !this->parent_->is_failed()) { this->trigger(); @@ -20,12 +20,12 @@ class ESP32ImprovProvisionedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovProvisioningTrigger final : public Trigger<> { +class ImprovBLEProvisioningTrigger final : public Trigger<> { public: - explicit ESP32ImprovProvisioningTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEProvisioningTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_PROVISIONING && !this->parent_->is_failed()) { this->trigger(); @@ -34,12 +34,12 @@ class ESP32ImprovProvisioningTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStartTrigger final : public Trigger<> { +class ImprovBLEStartTrigger final : public Trigger<> { public: - explicit ESP32ImprovStartTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStartTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if ((state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION) && !this->parent_->is_failed()) { @@ -49,12 +49,12 @@ class ESP32ImprovStartTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStateTrigger final : public Trigger { public: - explicit ESP32ImprovStateTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStateTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (!this->parent_->is_failed()) { this->trigger(state, error); @@ -63,12 +63,12 @@ class ESP32ImprovStateTrigger final : public Trigger { +class ImprovBLEStoppedTrigger final : public Trigger<> { public: - explicit ESP32ImprovStoppedTrigger(ESP32ImprovComponent *parent) : parent_(parent) { + explicit ImprovBLEStoppedTrigger(ImprovBLEComponent *parent) : parent_(parent) { parent->add_on_state_callback([this](improv::State state, improv::Error error) { if (state == improv::STATE_STOPPED && !this->parent_->is_failed()) { this->trigger(); @@ -77,10 +77,10 @@ class ESP32ImprovStoppedTrigger final : public Trigger<> { } protected: - ESP32ImprovComponent *parent_; + ImprovBLEComponent *parent_; }; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp similarity index 91% rename from esphome/components/esp32_improv/esp32_improv_component.cpp rename to esphome/components/improv_ble/improv_ble_component.cpp index 9ec6eb7bab..0a20beb33c 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -1,10 +1,7 @@ -#include "esp32_improv_component.h" +#include "improv_ble_component.h" #include -#include "esphome/components/bytebuffer/bytebuffer.h" -#include "esphome/components/esp32_ble/ble.h" -#include "esphome/components/esp32_ble_server/ble_2902.h" #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -15,11 +12,15 @@ #ifdef USE_ESP32 -namespace esphome::esp32_improv { +#include "esphome/components/bytebuffer/bytebuffer.h" +#include "esphome/components/esp32_ble/ble.h" +#include "esphome/components/esp32_ble_server/ble_2902.h" + +namespace esphome::improv_ble { using namespace bytebuffer; -static const char *const TAG = "esp32_improv.component"; +static const char *const TAG = "improv_ble.component"; static constexpr size_t IMPROV_MAX_LOG_BYTES = 128; static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome"; // command + data length + trailing byte @@ -38,9 +39,9 @@ static constexpr uint8_t IMPROV_SERVICE_DATA_SIZE = 8; static constexpr uint8_t IMPROV_PROTOCOL_ID_1 = 0x77; // 'P' << 1 | 'R' >> 7 static constexpr uint8_t IMPROV_PROTOCOL_ID_2 = 0x46; // 'I' << 1 | 'M' >> 7 -ESP32ImprovComponent::ESP32ImprovComponent() { global_improv_component = this; } +ImprovBLEComponent::ImprovBLEComponent() { global_improv_component = this; } -void ESP32ImprovComponent::setup() { +void ImprovBLEComponent::setup() { #ifdef USE_BINARY_SENSOR if (this->authorizer_ != nullptr) { this->authorizer_->add_on_state_callback([this](bool state) { @@ -66,7 +67,7 @@ void ESP32ImprovComponent::setup() { this->disable_loop(); } -void ESP32ImprovComponent::setup_characteristics() { +void ImprovBLEComponent::setup_characteristics() { this->status_ = this->service_->create_characteristic( improv::STATUS_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); BLEDescriptor *status_descriptor = new BLE2902(); @@ -104,11 +105,11 @@ void ESP32ImprovComponent::setup_characteristics() { this->setup_complete_ = true; } -void ESP32ImprovComponent::loop() { +void ImprovBLEComponent::loop() { if (!global_ble_server->is_running()) { if (this->state_ != improv::STATE_STOPPED) { this->state_ = improv::STATE_STOPPED; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } @@ -200,23 +201,19 @@ void ESP32ImprovComponent::loop() { } } -void ESP32ImprovComponent::set_status_indicator_state_(bool state) { +void ImprovBLEComponent::set_status_indicator_state_(bool state) { #ifdef USE_OUTPUT if (this->status_indicator_ == nullptr) return; if (this->status_indicator_state_ == state) return; this->status_indicator_state_ = state; - if (state) { - this->status_indicator_->turn_on(); - } else { - this->status_indicator_->turn_off(); - } + this->status_indicator_->set_state(state); #endif } #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG -const char *ESP32ImprovComponent::state_to_string_(improv::State state) { +const char *ImprovBLEComponent::state_to_string_(improv::State state) { switch (state) { case improv::STATE_STOPPED: return "STOPPED"; @@ -234,7 +231,7 @@ const char *ESP32ImprovComponent::state_to_string_(improv::State state) { } #endif -bool ESP32ImprovComponent::check_identify_() { +bool ImprovBLEComponent::check_identify_() { uint32_t now = millis(); bool identify = this->identify_start_ != 0 && now - this->identify_start_ <= this->identify_duration_; @@ -246,7 +243,7 @@ bool ESP32ImprovComponent::check_identify_() { return identify; } -void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertising) { +void ImprovBLEComponent::set_state_(improv::State state, bool update_advertising) { // Skip if state hasn't changed if (this->state_ == state) { return; @@ -274,12 +271,12 @@ void ESP32ImprovComponent::set_state_(improv::State state, bool update_advertisi // Advertise the new state via service data this->advertise_service_data_(); } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK this->state_callback_.call(this->state_, this->error_state_); #endif } -void ESP32ImprovComponent::set_error_(improv::Error error) { +void ImprovBLEComponent::set_error_(improv::Error error) { if (error != improv::ERROR_NONE) { ESP_LOGE(TAG, "Error: %d", error); } @@ -295,14 +292,14 @@ void ESP32ImprovComponent::set_error_(improv::Error error) { } } -void ESP32ImprovComponent::send_response_(std::span response) { +void ImprovBLEComponent::send_response_(std::span response) { // The BLE characteristic owns its value, so one exact-size copy is required here this->rpc_response_->set_value(std::vector(response.begin(), response.end())); if (this->state_ != improv::STATE_STOPPED) this->rpc_response_->notify(); } -void ESP32ImprovComponent::start() { +void ImprovBLEComponent::start() { if (this->should_start_ || this->state_ != improv::STATE_STOPPED) return; @@ -320,7 +317,7 @@ void ESP32ImprovComponent::start() { this->enable_loop(); } -void ESP32ImprovComponent::stop() { +void ImprovBLEComponent::stop() { this->should_start_ = false; // Wait before stopping the service to ensure all BLE clients see the state change. // This prevents clients from repeatedly reconnecting and wasting resources by allowing @@ -335,10 +332,10 @@ void ESP32ImprovComponent::stop() { }); } -float ESP32ImprovComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } +float ImprovBLEComponent::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } -void ESP32ImprovComponent::dump_config() { - ESP_LOGCONFIG(TAG, "ESP32 Improv:"); +void ImprovBLEComponent::dump_config() { + ESP_LOGCONFIG(TAG, "Improv BLE:"); #ifdef USE_BINARY_SENSOR LOG_BINARY_SENSOR(" ", "Authorizer", this->authorizer_); #endif @@ -347,7 +344,7 @@ void ESP32ImprovComponent::dump_config() { #endif } -void ESP32ImprovComponent::process_incoming_data_() { +void ImprovBLEComponent::process_incoming_data_() { if (this->incoming_data_.size() < 3) return; uint8_t length = this->incoming_data_[1]; @@ -422,7 +419,7 @@ void ESP32ImprovComponent::process_incoming_data_() { } } -void ESP32ImprovComponent::on_wifi_connect_timeout_() { +void ImprovBLEComponent::on_wifi_connect_timeout_() { this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_state_(improv::STATE_AUTHORIZED); #ifdef USE_BINARY_SENSOR @@ -433,7 +430,7 @@ void ESP32ImprovComponent::on_wifi_connect_timeout_() { wifi::global_wifi_component->clear_sta(); } -void ESP32ImprovComponent::check_wifi_connection_() { +void ImprovBLEComponent::check_wifi_connection_() { if (!wifi::global_wifi_component->is_connected()) { return; } @@ -447,7 +444,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { std::array buf; improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS); -#ifdef USE_ESP32_IMPROV_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL // Add next_url if configured (should be first per Improv BLE spec) this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif @@ -480,7 +477,7 @@ void ESP32ImprovComponent::check_wifi_connection_() { this->stop(); } -void ESP32ImprovComponent::advertise_service_data_() { +void ImprovBLEComponent::advertise_service_data_() { uint8_t service_data[IMPROV_SERVICE_DATA_SIZE] = {}; service_data[0] = IMPROV_PROTOCOL_ID_1; // PR service_data[1] = IMPROV_PROTOCOL_ID_2; // IM @@ -499,7 +496,7 @@ void ESP32ImprovComponent::advertise_service_data_() { esp32_ble::global_ble->advertising_set_service_data_and_name(std::span(service_data), false); } -void ESP32ImprovComponent::update_advertising_type_() { +void ImprovBLEComponent::update_advertising_type_() { uint32_t now = App.get_loop_component_start_time(); // If we're advertising the device name and it's been more than NAME_ADVERTISING_DURATION, switch back to service data @@ -524,21 +521,21 @@ void ESP32ImprovComponent::update_advertising_type_() { } } -void ESP32ImprovComponent::request_advertising_() { +void ImprovBLEComponent::request_advertising_() { if (this->advertising_requested_) return; this->advertising_requested_ = true; esp32_ble::global_ble->advertising_start(); } -void ESP32ImprovComponent::release_advertising_() { +void ImprovBLEComponent::release_advertising_() { if (!this->advertising_requested_) return; this->advertising_requested_ = false; esp32_ble::global_ble->advertising_stop(); } -improv::State ESP32ImprovComponent::get_initial_state_() const { +improv::State ImprovBLEComponent::get_initial_state_() const { #ifdef USE_BINARY_SENSOR // If we have an authorizer, start in awaiting authorization state return this->authorizer_ == nullptr ? improv::STATE_AUTHORIZED : improv::STATE_AWAITING_AUTHORIZATION; @@ -548,8 +545,8 @@ improv::State ESP32ImprovComponent::get_initial_state_() const { #endif } -ESP32ImprovComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +ImprovBLEComponent *global_improv_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/improv_ble/improv_ble_component.h similarity index 84% rename from esphome/components/esp32_improv/esp32_improv_component.h rename to esphome/components/improv_ble/improv_ble_component.h index a40d60552a..126663ed00 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -5,12 +5,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" -#include "esphome/components/esp32_ble_server/ble_characteristic.h" -#include "esphome/components/esp32_ble_server/ble_server.h" #include "esphome/components/improv_base/improv_base.h" #include "esphome/components/wifi/wifi_component.h" -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK #include "esphome/core/automation.h" #endif @@ -25,17 +23,23 @@ #include #include +// ESP-IDF is currently the only target platform with a BLE GATT server, so it is +// the only backend this component has. The Python side keeps the platform table +// (BLE_SERVER_BACKENDS in __init__.py); a second backend adds another arm here. #ifdef USE_ESP32 +#include "esphome/components/esp32_ble_server/ble_characteristic.h" +#include "esphome/components/esp32_ble_server/ble_server.h" + #include -namespace esphome::esp32_improv { +namespace esphome::improv_ble { using namespace esp32_ble_server; -class ESP32ImprovComponent final : public Component, public improv_base::ImprovBase { +class ImprovBLEComponent final : public Component, public improv_base::ImprovBase { public: - ESP32ImprovComponent(); + ImprovBLEComponent(); void dump_config() override; void loop() override; void setup() override; @@ -47,7 +51,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB bool is_active() const { return this->state_ != improv::STATE_STOPPED; } bool should_start() const { return this->should_start_; } -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK template void add_on_state_callback(F &&callback) { this->state_callback_.add(std::forward(callback)); } @@ -68,9 +72,6 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB 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}; @@ -80,6 +81,14 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB 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}; @@ -95,16 +104,11 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB output::BinaryOutput *status_indicator_{nullptr}; #endif - improv::State state_{improv::STATE_STOPPED}; - improv::Error error_state_{improv::ERROR_NONE}; -#ifdef USE_ESP32_IMPROV_STATE_CALLBACK +#ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif - 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_(); @@ -125,8 +129,8 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB }; // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -extern ESP32ImprovComponent *global_improv_component; +extern ImprovBLEComponent *global_improv_component; -} // namespace esphome::esp32_improv +} // namespace esphome::improv_ble #endif diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index a34e2ab793..0231791e9b 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -70,7 +70,7 @@ FINAL_VALIDATE_SCHEMA = validate_transport async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - await improv_base.setup_improv_core(var, config, "improv_serial") + await improv_base.setup_improv_core(var, config) cg.add_define("USE_IMPROV_SERIAL") if (uart_id := config.get(CONF_UART_ID)) is not None: cg.add(var.set_uart(await cg.get_variable(uart_id))) diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index ffa7b79d9b..3827fb6ed4 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -208,7 +208,7 @@ void ImprovSerialComponent::add_webserver_urls_(improv::RpcResponseBuilder &buil void ImprovSerialComponent::send_settings_response_(improv::Command command) { std::array buf; improv::RpcResponseBuilder builder(buf, command); -#ifdef USE_IMPROV_SERIAL_NEXT_URL +#ifdef USE_IMPROV_NEXT_URL this->add_next_url_(builder, MAX_NEXT_URL_LEN); #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 68cdd75214..c7d89c76d6 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -55,7 +55,7 @@ static const uint8_t IMPROV_SERIAL_VERSION = 1; #ifdef USE_WIFI // Wi-Fi connect failure timers: a fresh provision reports at 30 s (stock behavior), while // switching networks on an already-connected device (disconnect + reconnect) can legitimately -// take longer; 90 s matches esp32_improv's default wifi_timeout. +// take longer; 90 s matches improv_ble's default wifi_timeout. static const uint32_t WIFI_CONNECT_TIMEOUT_MS = 30000; static const uint32_t WIFI_SWITCH_TIMEOUT_MS = 90000; #endif diff --git a/esphome/components/ina219/sensor.py b/esphome/components/ina219/sensor.py index 97482f81a0..0a81adc785 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 4fd98fbcd4..af1793450c 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 b7b94a248b..20cf1fee45 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 7bb589f0b1..b6cf0e38f3 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 d04c82ea96..47ca5bf07f 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 5a909738c6..67a40fa349 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -1,166 +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()); - - // Register as listener for received IR data - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } -} - 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 b6863e37ce..d81e3e2e38 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -4,130 +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 - 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_; } @@ -136,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 84a207020e..f6524021c0 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 019c3ee074..22d664cd55 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 82e8ba8df8..a68cb57519 100644 --- a/esphome/components/integration/sensor.py +++ b/esphome/components/integration/sensor.py @@ -11,17 +11,13 @@ from esphome.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, ) -from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType integration_ns = cg.esphome_ns.namespace("integration") IntegrationSensor = integration_ns.class_( "IntegrationSensor", sensor.Sensor, cg.Component ) -ResetAction = integration_ns.class_("ResetAction", automation.Action) -SetValueAction = integration_ns.class_("SetValueAction", automation.Action) IntegrationSensorTime = integration_ns.enum("IntegrationSensorTime") INTEGRATION_TIMES = { @@ -106,46 +102,23 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_restore(config[CONF_RESTORE])) -@automation.register_action( +automation.register_apply_action( "sensor.integration.reset", - ResetAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(IntegrationSensor), } ), - synchronous=True, + automation.ApplyCall("reset()"), ) -async def sensor_integration_reset_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var - -@automation.register_action( +automation.register_apply_action( "sensor.integration.set_value", - SetValueAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(IntegrationSensor), cv.Required(CONF_VALUE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_VALUE, "set_value", cg.float_), ) -async def sensor_integration_set_value_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) - cg.add(var.set_value(template_)) - return var diff --git a/esphome/components/internal_temperature/internal_temperature.h b/esphome/components/internal_temperature/internal_temperature.h index 90831cf211..6a9889ef29 100644 --- a/esphome/components/internal_temperature/internal_temperature.h +++ b/esphome/components/internal_temperature/internal_temperature.h @@ -13,6 +13,9 @@ namespace esphome::internal_temperature { class InternalTemperatureSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) InternalTemperatureSensor()` would zero-fill .bss that is already zero. + InternalTemperatureSensor() {} + #if defined(USE_ESP32) || (defined(USE_ZEPHYR) && defined(USE_NRF52)) void setup() override; #endif // USE_ESP32 || (USE_ZEPHYR && USE_NRF52) diff --git a/esphome/components/ir_rf_base/__init__.py b/esphome/components/ir_rf_base/__init__.py new file mode 100644 index 0000000000..55536c131b --- /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 0000000000..9aa845f269 --- /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 0000000000..c6dc877696 --- /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 3218889721..548ba2b132 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,7 +3,7 @@ from typing import Any import esphome.codegen as cg -from esphome.components import infrared, 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 @@ -25,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 ), } ), @@ -77,13 +77,11 @@ 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: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) # Set receiver demodulation frequency if specified (metadata only, no hardware effect) if CONF_RECEIVER_FREQUENCY in config: diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index c13c6198cb..88b1560608 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,27 +27,16 @@ 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); - - if (this->receiver_ != nullptr) { - this->receiver_->register_listener(this); - } } void RfProxy::dump_config() { @@ -108,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) { @@ -122,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 5fc683354b..a45b0ac472 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,19 +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 - 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 a243909837..1519fe9908 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,7 +1,7 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import radio_frequency, remote_receiver, remote_transmitter +from esphome.components import 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 @@ -22,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 ), } ), @@ -62,9 +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: - receiver = await cg.get_variable(config[CONF_REMOTE_RECEIVER_ID]) - cg.add(var.set_receiver(receiver)) + await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 57bf86c4c6..b5f3019a12 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 179c2e5f63..a6511b63c4 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -777,12 +777,12 @@ void IT8951Display::update() { } void IT8951Display::update_mode(UpdateMode mode) { - if (!this->is_ready()) - return; if (mode == UPDATE_MODE_NONE) { - ESP_LOGW(TAG, "Unknown update mode"); + this->update(); return; } + if (!this->is_ready()) + return; this->start_update_(mode); } @@ -855,7 +855,7 @@ void IT8951Display::apply_transform_(int &x, int &y) const { } bool IT8951Display::rotate_coordinates_(int &x, int &y) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return false; this->apply_transform_(x, y); if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) @@ -929,7 +929,7 @@ void IT8951Display::fill(Color color) { void HOT IT8951Display::draw_pixel_at(int x, int y, Color color) { if (this->buffer_ == nullptr) return; - App.feed_wdt(); + this->feed_wdt_per_pixel_(); if (!this->rotate_coordinates_(x, y)) return; this->write_pixel_native_(static_cast(x), static_cast(y), color); diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index a5ed03e8c4..fcdc6d1d19 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 af7eb7e733..618c56faa1 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 984134b95f..3ecfce8b69 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -1,4 +1,6 @@ #include "json_util.h" + +#include #include "esphome/core/log.h" // ArduinoJson::Allocator is included via ArduinoJson.h in json_util.h @@ -44,11 +46,7 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { ESP_LOGE(TAG, "No data to parse"); return JsonObject(); // return unbound object } -#ifdef USE_PSRAM - JsonDocument json_document(&global_json_allocator); -#else - JsonDocument json_document; -#endif + JsonDocument json_document(heap_json_allocator()); if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); return JsonObject(); // return unbound object @@ -66,6 +64,35 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } +JsonBuilder::JsonBuilder() : doc_(heap_json_allocator()) {} +#ifdef USE_JSON_ARENA +JsonBuilder::JsonBuilder(ArduinoJson::Allocator *allocator) : doc_(allocator) {} +#endif + +ArduinoJson::Allocator *heap_json_allocator() { +#ifdef USE_PSRAM + return &global_json_allocator; +#else + return ArduinoJson::detail::DefaultAllocator::instance(); +#endif +} + +size_t JsonBuilder::serialize_to(char *buf, size_t cap) { + if (doc_.overflowed()) { + ESP_LOGE(TAG, "JSON document overflow"); + // Same contract as serializeJson; written by hand so no "{}" literal lives in RAM on ESP8266 + size_t n = 0; + if (n < cap) + buf[n++] = '{'; + if (n < cap) + buf[n++] = '}'; + if (n < cap) + buf[n] = '\0'; + return n; + } + return serializeJson(doc_, buf, cap); +} + SerializationBuffer<> JsonBuilder::serialize() { // =========================================================================================== // CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING @@ -107,17 +134,7 @@ SerializationBuffer<> JsonBuilder::serialize() { constexpr size_t buf_size = SerializationBuffer<>::BUFFER_SIZE; SerializationBuffer<> result(buf_size - 1); // Max content size (reserve 1 for null) - if (doc_.overflowed()) { - ESP_LOGE(TAG, "JSON document overflow"); - auto *buf = result.data_writable_(); - buf[0] = '{'; - buf[1] = '}'; - buf[2] = '\0'; - result.set_size_(2); - return result; - } - - size_t size = serializeJson(doc_, result.data_writable_(), buf_size); + size_t size = this->serialize_to(result.data_writable_(), buf_size); if (size < buf_size) { // Fits in stack buffer - update size to actual length result.set_size_(size); @@ -132,7 +149,7 @@ SerializationBuffer<> JsonBuilder::serialize() { size_t heap_size = buf_size * 2; while (heap_size <= max_heap_size) { result.reallocate_heap_(heap_size - 1); - size = serializeJson(doc_, result.data_writable_(), heap_size); + size = this->serialize_to(result.data_writable_(), heap_size); if (size < heap_size) { result.set_size_(size); return result; diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9f51d9927b..be12d0aa38 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -165,9 +167,89 @@ inline JsonDocument parse_json(const std::string &data) { return parse_json(reinterpret_cast(data.c_str()), data.size()); } +/// The allocator a JsonBuilder uses by default (PSRAM first when available) +ArduinoJson::Allocator *heap_json_allocator(); + +#ifdef USE_JSON_ARENA +/// Size of one ArduinoJson slot pool, the first allocation every document makes (1 KB on 32 bit targets) +constexpr size_t JSON_POOL_BYTES = ARDUINOJSON_POOL_CAPACITY * sizeof(ArduinoJson::detail::VariantData); +/// One pool plus room for copied string nodes; a 40 option select with linked options fits +constexpr size_t JSON_ARENA_SIZE = JSON_POOL_BYTES + 1152; +static_assert(sizeof(void *) != 4 || JSON_ARENA_SIZE == 2176, "the arena was sized for a 1 KB pool"); + +/// Bump allocator over a fixed buffer for a document built and serialized in one scope. What the +/// buffer cannot hold goes to the heap allocator; nothing is freed until the arena goes away. +template class JsonArena final : public ArduinoJson::Allocator { + public: + // Takes what the buffer cannot hold + explicit JsonArena(ArduinoJson::Allocator *fallback = heap_json_allocator()) : fallback_(fallback) {} + // The document points into buf_ + JsonArena(const JsonArena &) = delete; + JsonArena &operator=(const JsonArena &) = delete; + + void *allocate(size_t size) override { + if (size > N) { + return this->fallback_->allocate(size); // also keeps the rounding below from wrapping + } + size = (size + ALIGN - 1) & ~(ALIGN - 1); + if (size > N - this->used_) { + return this->fallback_->allocate(size); + } + this->last_ = this->used_; + this->used_ += size; + return this->buf_ + this->last_; + } + void deallocate(void *ptr) override { + if (!this->owns_(ptr)) { + this->fallback_->deallocate(ptr); + } + } + void *reallocate(void *ptr, size_t new_size) override { + if (!this->owns_(ptr)) { + return this->fallback_->reallocate(ptr, new_size); + } + const size_t off = static_cast(ptr) - this->buf_; + const size_t size = new_size > N ? N + ALIGN : (new_size + ALIGN - 1) & ~(ALIGN - 1); + const bool newest = off == this->last_; + if (newest && size <= N - off) { + this->used_ = off + size; // the newest block grows or shrinks in place + return ptr; + } + // An older block's size is unknown; copying to the end of the buffer stays in bounds + const size_t old_size = newest ? this->used_ - off : N - off; + void *moved = this->fallback_->allocate(new_size); + if (moved == nullptr) { + return nullptr; // the caller keeps ptr, so its arena space stays reserved + } + std::memcpy(moved, ptr, std::min(new_size, old_size)); + if (newest) { + this->used_ = off; // it moved to the heap, so its arena space is free again + } + return moved; + } + /// Bytes of the buffer handed out so far + size_t used() const { return this->used_; } + + private: + static constexpr size_t ALIGN = alignof(std::max_align_t); + bool owns_(const void *ptr) const { return ptr >= this->buf_ && ptr < this->buf_ + N; } + ArduinoJson::Allocator *fallback_; + alignas(ALIGN) uint8_t buf_[N]; + size_t used_{0}; + size_t last_{0}; +}; +#endif // USE_JSON_ARENA + /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: + // Out of line: inlining the JsonDocument constructor duplicates it at every call site + JsonBuilder(); +#ifdef USE_JSON_ARENA + // The builder must not outlive the allocator + explicit JsonBuilder(ArduinoJson::Allocator *allocator); +#endif + JsonObject root() { if (!root_created_) { root_ = doc_.to(); @@ -176,17 +258,16 @@ class JsonBuilder { return root_; } + /// Serialize into a caller owned buffer. Returns the length; cap or more means it did not fit + /// and buf holds a truncated, unterminated copy. An overflowed document serializes as "{}". + size_t serialize_to(char *buf, size_t cap); + /// Serialize the JSON document to a SerializationBuffer (stack-first allocation) /// Uses 512-byte stack buffer by default, falls back to heap for larger JSON SerializationBuffer<> serialize(); private: -#ifdef USE_PSRAM - SpiRamAllocator allocator_; - JsonDocument doc_{&allocator_}; -#else JsonDocument doc_; -#endif JsonObject root_; bool root_created_{false}; }; diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index bf47b6df88..dedd960c36 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 c9eeabeb2d..e49cac4610 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 3e007d1310..5b7087362f 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 51d23991e2..40e7447d66 100644 --- a/esphome/components/kuntze/sensor.py +++ b/esphome/components/kuntze/sensor.py @@ -101,31 +101,11 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_PH in config: - conf = config[CONF_PH] - sens = await sensor.new_sensor(conf) - cg.add(var.set_ph_sensor(sens)) - if CONF_TEMPERATURE in config: - conf = config[CONF_TEMPERATURE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_temperature_sensor(sens)) - if CONF_DIS1 in config: - conf = config[CONF_DIS1] - sens = await sensor.new_sensor(conf) - cg.add(var.set_dis1_sensor(sens)) - if CONF_DIS2 in config: - conf = config[CONF_DIS2] - sens = await sensor.new_sensor(conf) - cg.add(var.set_dis2_sensor(sens)) - if CONF_REDOX in config: - conf = config[CONF_REDOX] - sens = await sensor.new_sensor(conf) - cg.add(var.set_redox_sensor(sens)) - if CONF_EC in config: - conf = config[CONF_EC] - sens = await sensor.new_sensor(conf) - cg.add(var.set_ec_sensor(sens)) - if CONF_OCI in config: - conf = config[CONF_OCI] - sens = await sensor.new_sensor(conf) - cg.add(var.set_oci_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PH, var.set_ph_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_DIS1, var.set_dis1_sensor) + await sensors(CONF_DIS2, var.set_dis2_sensor) + await sensors(CONF_REDOX, var.set_redox_sensor) + await sensors(CONF_EC, var.set_ec_sensor) + await sensors(CONF_OCI, var.set_oci_sensor) diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index a5dda6ca43..36e5bce8e3 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -275,8 +275,4 @@ void Lc709203f::set_pack_size(uint16_t pack_size) { // not cause an error or crash, so I am not doing any additional checking here. } -void Lc709203f::set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } - -void Lc709203f::set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } - } // namespace esphome::lc709203f diff --git a/esphome/components/lc709203f/lc709203f.h b/esphome/components/lc709203f/lc709203f.h index 46f773873a..e9c60e285f 100644 --- a/esphome/components/lc709203f/lc709203f.h +++ b/esphome/components/lc709203f/lc709203f.h @@ -26,8 +26,8 @@ class Lc709203f final : public sensor::Sensor, public PollingComponent, public i void dump_config() override; void set_pack_size(uint16_t pack_size); - void set_thermistor_b_constant(uint16_t b_constant); - void set_pack_voltage(LC709203FBatteryVoltage pack_voltage); + void set_thermistor_b_constant(uint16_t b_constant) { this->b_constant_ = b_constant; } + void set_pack_voltage(LC709203FBatteryVoltage pack_voltage) { this->pack_voltage_ = pack_voltage; } void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) { battery_remaining_sensor_ = battery_remaining_sensor; diff --git a/esphome/components/lc709203f/sensor.py b/esphome/components/lc709203f/sensor.py index 3319c9be4b..0281609edf 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 19786f38d3..d3179d9b30 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 b0b9591d37..0000000000 --- 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 2b68733532..f764e89f9b 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 59a9558331..3826455986 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 061846f1f1..6c7601df3d 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 3500d704a1..b1891a5935 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 e89e3d5997..936ad83d39 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 ca42b3a1d3..5b523d0944 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 6d8053ddd6..646890d43f 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 25c61a4825..8a4e5186ef 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 80cff014c0..9e93727549 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 5a1ea2e6a5..9f5c868310 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 1a81c330ad..0c0c01c69c 100644 --- a/esphome/components/ld2412/number/__init__.py +++ b/esphome/components/ld2412/number/__init__.py @@ -87,42 +87,45 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if light_threshold_config := config.get(CONF_LIGHT_THRESHOLD): - n = await number.new_number( - light_threshold_config, min_value=0, max_value=255, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_threshold_number(n)) - if max_distance_gate_config := config.get(CONF_MAX_DISTANCE_GATE): - n = await number.new_number( - max_distance_gate_config, min_value=2, max_value=13, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_max_distance_gate_number(n)) - if min_distance_gate_config := config.get(CONF_MIN_DISTANCE_GATE): - n = await number.new_number( - min_distance_gate_config, min_value=1, max_value=12, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_min_distance_gate_number(n)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_LIGHT_THRESHOLD, + hub.set_light_threshold_number, + min_value=0, + max_value=255, + step=1, + ) + await numbers( + CONF_MAX_DISTANCE_GATE, + hub.set_max_distance_gate_number, + min_value=2, + max_value=13, + step=1, + ) + await numbers( + CONF_MIN_DISTANCE_GATE, + hub.set_min_distance_gate_number, + min_value=1, + max_value=12, + step=1, + ) for x in range(14): if gate_conf := config.get(f"gate_{x}"): move_config = gate_conf[CONF_MOVE_THRESHOLD] - n = cg.new_Pvariable(move_config[CONF_ID], x) - await number.register_number( - n, move_config, min_value=0, max_value=100, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) + n = await number.new_number(move_config, min_value=0, max_value=100, step=1) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID], x) - await number.register_number( - n, still_config, min_value=0, max_value=100, step=1 + n = await number.new_number( + still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_still_threshold_number(x, n)) - if timeout_config := config.get(CONF_TIMEOUT): - n = await number.new_number(timeout_config, min_value=0, max_value=900, step=1) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_timeout_number(n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) + await numbers( + CONF_TIMEOUT, + hub.set_timeout_number, + min_value=0, + max_value=900, + step=1, + ) diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index 8d12bad115..a0a525a810 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -2,8 +2,6 @@ namespace esphome::ld2412 { -GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} - void GateThresholdNumber::control(float value) { this->publish_state(value); this->parent_->set_gate_threshold(); diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 918b6dfad1..308da43a34 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -7,10 +7,10 @@ namespace esphome::ld2412 { class GateThresholdNumber final : public number::Number, public Parented { public: - GateThresholdNumber(uint8_t gate); + // Not "= default": that makes new(p) T() zero-fill the object at every codegen site before the ctor runs. + GateThresholdNumber() {} protected: - uint8_t gate_; void control(float value) override; }; diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index f62d523af3..710b47957c 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightThresholdNumber final : public number::Number, public Parented { public: - LightThresholdNumber() = default; + // User provided, not "= default": `new(p) LightThresholdNumber()` would zero-fill .bss that is already zero. + LightThresholdNumber() {} protected: void control(float value) override; diff --git a/esphome/components/ld2412/select/__init__.py b/esphome/components/ld2412/select/__init__.py index 02ecf2c30f..40a075e5d5 100644 --- a/esphome/components/ld2412/select/__init__.py +++ b/esphome/components/ld2412/select/__init__.py @@ -50,36 +50,34 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_baud_rate_select(s)) - if distance_resolution_config := config.get(CONF_DISTANCE_RESOLUTION): - s = await select.new_select( - distance_resolution_config, options=["0.2m", "0.5m", "0.75m"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_distance_resolution_select(s)) - if light_function_config := config.get(CONF_LIGHT_FUNCTION): - s = await select.new_select( - light_function_config, options=["off", "below", "above"] - ) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_light_function_select(s)) - if out_pin_level_config := config.get(CONF_OUT_PIN_LEVEL): - s = await select.new_select(out_pin_level_config, options=["low", "high"]) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_out_pin_level_select(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_DISTANCE_RESOLUTION, + hub.set_distance_resolution_select, + options=["0.2m", "0.5m", "0.75m"], + ) + await selects( + CONF_LIGHT_FUNCTION, + hub.set_light_function_select, + options=["off", "below", "above"], + ) + await selects( + CONF_OUT_PIN_LEVEL, + hub.set_out_pin_level_select, + options=["low", "high"], + ) diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index 46ec9be1d1..527b1a1e93 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BaudRateSelect final : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index be8dba90b5..d1bc15dea9 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class DistanceResolutionSelect final : public select::Select, public Parented { public: - DistanceResolutionSelect() = default; + // User provided, not "= default": `new(p) DistanceResolutionSelect()` would zero-fill .bss that is already zero. + DistanceResolutionSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index c8988fda78..0867f3b1c2 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class LightOutControlSelect final : public select::Select, public Parented { public: - LightOutControlSelect() = default; + // User provided, not "= default": `new(p) LightOutControlSelect()` would zero-fill .bss that is already zero. + LightOutControlSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2412/sensor.py b/esphome/components/ld2412/sensor.py index 0b6e676931..e236a7d5f3 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 e7f71222fd..d064305597 100644 --- a/esphome/components/ld2412/switch/__init__.py +++ b/esphome/components/ld2412/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - LD2412_component = await cg.get_variable(config[CONF_LD2412_ID]) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_bluetooth_switch(s)) - if engineering_mode_config := config.get(CONF_ENGINEERING_MODE): - s = await switch.new_switch(engineering_mode_config) - await cg.register_parented(s, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_engineering_mode_switch(s)) + hub = await cg.get_variable(config[CONF_LD2412_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_ENGINEERING_MODE, hub.set_engineering_mode_switch) diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 8fd4a86e43..e753613cdf 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class BluetoothSwitch final : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index defeb4c76b..279128ddbc 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2412 { class EngineeringModeSwitch final : public switch_::Switch, public Parented { public: - EngineeringModeSwitch() = default; + // User provided, not "= default": `new(p) EngineeringModeSwitch()` would zero-fill .bss that is already zero. + EngineeringModeSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld2412/text_sensor.py b/esphome/components/ld2412/text_sensor.py index c8e9f42ef3..63856d9f00 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 76b42c0362..ca3891a97c 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 cfcffd0922..533f3b91aa 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 448639c911..200f4e202f 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 cd66064e47..6f3c73b9d1 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 f98d63585b..89cfd3a515 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 cee8f25c1f..58ebc9cf5b 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 779d151fd9..c7c1b6b46a 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 42cadd2052..d23f1bebf8 100644 --- a/esphome/components/ld2450/button/__init__.py +++ b/esphome/components/ld2450/button/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if factory_reset_config := config.get(CONF_FACTORY_RESET): - b = await button.new_button(factory_reset_config) - await cg.register_parented(b, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_factory_reset_button(b)) - if restart_config := config.get(CONF_RESTART): - b = await button.new_button(restart_config) - await cg.register_parented(b, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_restart_button(b)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + buttons = button.sub_buttons(config, parent=hub) + await buttons(CONF_FACTORY_RESET, hub.set_factory_reset_button) + await buttons(CONF_RESTART, hub.set_restart_button) diff --git a/esphome/components/ld2450/button/factory_reset_button.h b/esphome/components/ld2450/button/factory_reset_button.h index 392fc67ffd..71dc19a6cd 100644 --- a/esphome/components/ld2450/button/factory_reset_button.h +++ b/esphome/components/ld2450/button/factory_reset_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class FactoryResetButton : public button::Button, public Parented { public: - FactoryResetButton() = default; + // User provided, not "= default": `new(p) FactoryResetButton()` would zero-fill .bss that is already zero. + FactoryResetButton() {} protected: void press_action() override; diff --git a/esphome/components/ld2450/button/restart_button.h b/esphome/components/ld2450/button/restart_button.h index 9219011f8b..87b1a2bbd1 100644 --- a/esphome/components/ld2450/button/restart_button.h +++ b/esphome/components/ld2450/button/restart_button.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class RestartButton : public button::Button, public Parented { public: - RestartButton() = default; + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} protected: void press_action() override; diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index c4f06ad224..d6285cce8a 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 4f242076d6..237c7c4c23 100644 --- a/esphome/components/ld2450/number/__init__.py +++ b/esphome/components/ld2450/number/__init__.py @@ -80,16 +80,15 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if presence_timeout_config := config.get(CONF_PRESENCE_TIMEOUT): - n = await number.new_number( - presence_timeout_config, - min_value=0, - max_value=3600, - step=1, - ) - await cg.register_parented(n, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_presence_timeout_number(n)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + numbers = number.sub_numbers(config, parent=hub) + await numbers( + CONF_PRESENCE_TIMEOUT, + hub.set_presence_timeout_number, + min_value=0, + max_value=3600, + step=1, + ) for zone_num in range(MAX_ZONES): if zone_conf := config.get(f"zone_{zone_num + 1}"): zone_x1_config = zone_conf.get(CONF_X1) @@ -97,27 +96,27 @@ async def to_code(config: ConfigType) -> None: await number.register_number( x1, zone_x1_config, min_value=-4860, max_value=4860, step=1 ) - await cg.register_parented(x1, config[CONF_LD2450_ID]) + await cg.register_parented(x1, hub) zone_y1_config = zone_conf.get(CONF_Y1) y1 = cg.new_Pvariable(zone_y1_config[CONF_ID], zone_num) await number.register_number( y1, zone_y1_config, min_value=0, max_value=7560, step=1 ) - await cg.register_parented(y1, config[CONF_LD2450_ID]) + await cg.register_parented(y1, hub) zone_x2_config = zone_conf.get(CONF_X2) x2 = cg.new_Pvariable(zone_x2_config[CONF_ID], zone_num) await number.register_number( x2, zone_x2_config, min_value=-4860, max_value=4860, step=1 ) - await cg.register_parented(x2, config[CONF_LD2450_ID]) + await cg.register_parented(x2, hub) zone_y2_config = zone_conf.get(CONF_Y2) y2 = cg.new_Pvariable(zone_y2_config[CONF_ID], zone_num) await number.register_number( y2, zone_y2_config, min_value=0, max_value=7560, step=1 ) - await cg.register_parented(y2, config[CONF_LD2450_ID]) + await cg.register_parented(y2, hub) - cg.add(ld2450_component.set_zone_numbers(zone_num, x1, y1, x2, y2)) + cg.add(hub.set_zone_numbers(zone_num, x1, y1, x2, y2)) diff --git a/esphome/components/ld2450/number/presence_timeout_number.h b/esphome/components/ld2450/number/presence_timeout_number.h index 09c8afca55..8c44fa39dc 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.h +++ b/esphome/components/ld2450/number/presence_timeout_number.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class PresenceTimeoutNumber : public number::Number, public Parented { public: - PresenceTimeoutNumber() = default; + // User provided, not "= default": `new(p) PresenceTimeoutNumber()` would zero-fill .bss that is already zero. + PresenceTimeoutNumber() {} protected: void control(float value) override; diff --git a/esphome/components/ld2450/select/__init__.py b/esphome/components/ld2450/select/__init__.py index d91b42426a..779dfc2a35 100644 --- a/esphome/components/ld2450/select/__init__.py +++ b/esphome/components/ld2450/select/__init__.py @@ -33,31 +33,28 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if baud_rate_config := config.get(CONF_BAUD_RATE): - s = await select.new_select( - baud_rate_config, - options=[ - "9600", - "19200", - "38400", - "57600", - "115200", - "230400", - "256000", - "460800", - ], - ) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_baud_rate_select(s)) - if zone_type_config := config.get(CONF_ZONE_TYPE): - s = await select.new_select( - zone_type_config, - options=[ - "Disabled", - "Detection", - "Filter", - ], - ) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_zone_type_select(s)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + selects = select.sub_selects(config, parent=hub) + await selects( + CONF_BAUD_RATE, + hub.set_baud_rate_select, + options=[ + "9600", + "19200", + "38400", + "57600", + "115200", + "230400", + "256000", + "460800", + ], + ) + await selects( + CONF_ZONE_TYPE, + hub.set_zone_type_select, + options=[ + "Disabled", + "Detection", + "Filter", + ], + ) diff --git a/esphome/components/ld2450/select/baud_rate_select.h b/esphome/components/ld2450/select/baud_rate_select.h index cb53118170..af4c477dff 100644 --- a/esphome/components/ld2450/select/baud_rate_select.h +++ b/esphome/components/ld2450/select/baud_rate_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BaudRateSelect : public select::Select, public Parented { public: - BaudRateSelect() = default; + // User provided, not "= default": `new(p) BaudRateSelect()` would zero-fill .bss that is already zero. + BaudRateSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2450/select/zone_type_select.h b/esphome/components/ld2450/select/zone_type_select.h index 566346eb48..cf79c2324d 100644 --- a/esphome/components/ld2450/select/zone_type_select.h +++ b/esphome/components/ld2450/select/zone_type_select.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class ZoneTypeSelect : public select::Select, public Parented { public: - ZoneTypeSelect() = default; + // User provided, not "= default": `new(p) ZoneTypeSelect()` would zero-fill .bss that is already zero. + ZoneTypeSelect() {} protected: void control(size_t index) override; diff --git a/esphome/components/ld2450/sensor.py b/esphome/components/ld2450/sensor.py index 40462e202d..e714d353d1 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 084f79ee1b..bb920f6c76 100644 --- a/esphome/components/ld2450/switch/__init__.py +++ b/esphome/components/ld2450/switch/__init__.py @@ -37,12 +37,7 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - ld2450_component = await cg.get_variable(config[CONF_LD2450_ID]) - if bluetooth_config := config.get(CONF_BLUETOOTH): - s = await switch.new_switch(bluetooth_config) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_bluetooth_switch(s)) - if multi_target_config := config.get(CONF_MULTI_TARGET): - s = await switch.new_switch(multi_target_config) - await cg.register_parented(s, config[CONF_LD2450_ID]) - cg.add(ld2450_component.set_multi_target_switch(s)) + hub = await cg.get_variable(config[CONF_LD2450_ID]) + switches = switch.sub_switches(config, parent=hub) + await switches(CONF_BLUETOOTH, hub.set_bluetooth_switch) + await switches(CONF_MULTI_TARGET, hub.set_multi_target_switch) diff --git a/esphome/components/ld2450/switch/bluetooth_switch.h b/esphome/components/ld2450/switch/bluetooth_switch.h index 3d48a89b57..8b118a7b8c 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.h +++ b/esphome/components/ld2450/switch/bluetooth_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class BluetoothSwitch : public switch_::Switch, public Parented { public: - BluetoothSwitch() = default; + // User provided, not "= default": `new(p) BluetoothSwitch()` would zero-fill .bss that is already zero. + BluetoothSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld2450/switch/multi_target_switch.h b/esphome/components/ld2450/switch/multi_target_switch.h index 739f308cce..d711a2d2d2 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.h +++ b/esphome/components/ld2450/switch/multi_target_switch.h @@ -7,7 +7,8 @@ namespace esphome::ld2450 { class MultiTargetSwitch : public switch_::Switch, public Parented { public: - MultiTargetSwitch() = default; + // User provided, not "= default": `new(p) MultiTargetSwitch()` would zero-fill .bss that is already zero. + MultiTargetSwitch() {} protected: void write_state(bool state) override; diff --git a/esphome/components/ld2450/text_sensor.py b/esphome/components/ld2450/text_sensor.py index a8b978ef48..3eaf6c7fca 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 74095d5ded..36405f0660 100644 --- a/esphome/components/ld6002b/binary_sensor.py +++ b/esphome/components/ld6002b/binary_sensor.py @@ -40,9 +40,8 @@ CONFIG_SCHEMA = ( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_LD6002B_ID]) - if target_config := config.get(CONF_TARGET): - sens = await binary_sensor.new_binary_sensor(target_config) - cg.add(hub.set_presence_binary_sensor(sens)) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_TARGET, hub.set_presence_binary_sensor) for i in range(MAX_TARGETS): if target_config := config.get(f"target_{i + 1}"): diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index 73fc7df331..aa34ad9d39 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -301,14 +301,10 @@ void LD6002BComponent::setup() { target_display_controlled = true; // Nothing reports this switch back, so its restored state is the only state // there is. Restoring through the switch keeps its inversion in the path: - // the restored value is logical, and turn_on()/turn_off() are what turn it + // the restored value is logical, and driving the switch is what turns it // into the raw command, the published state and the stream flag. const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true); - if (state) { - this->target_display_switch_->turn_on(); - } else { - this->target_display_switch_->turn_off(); - } + this->target_display_switch_->control(state); } #endif if (!target_display_controlled) { @@ -328,11 +324,7 @@ void LD6002BComponent::setup() { // The switch owns the stream, so it is also what applies the restored state: // driving it rather than the module keeps the entity's inversion in the path. const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->point_cloud_switch_->turn_on(); - } else { - this->point_cloud_switch_->turn_off(); - } + this->point_cloud_switch_->control(state); } #endif if (!point_cloud_controlled) { @@ -375,11 +367,7 @@ void LD6002BComponent::setup() { // Driving the switch applies its inversion; it also marks the restored value // as reported, so the work mode fallback runs on that until the query lands. const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false); - if (state) { - this->low_power_switch_->turn_on(); - } else { - this->low_power_switch_->turn_off(); - } + this->low_power_switch_->control(state); } #else bool want_low_power = false; diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index cceefb3837..5653f8b944 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 0e8e2e80e7..de3dde15cc 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 b0a243f2e4..1df0f67572 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 e5e7c3dcbe..bb7f8de3fe 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 50dc787799..5d630fec6c 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 8686a41e64..1f5de35d2c 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 f909db4f0f..8f6760c0fc 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 b1a37cb225..ca88835f69 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 01b276005d..f97a5a19a7 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 0000000000..77e30b3a7d --- /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 0ab064e3e1..d7d93249c5 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 0000000000..23da6ff396 --- /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 c1012774c8..18b412c460 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 f0ea0228b7..0b3d55f6d2 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 716ccfad2b..e8be88df60 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 ab9624c364..b175620bbf 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 ced15dfc60..2e963479f7 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 7eaba9b117..a616d26571 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 ba3fba6c12..e32dbde952 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 3ae15f9ee5..dc6266b2fd 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 4eb5208c96..0295fb36b8 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 4251565e85..7e54fccebc 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 e3352de727..a226690853 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 aaa1176f9f..98df98ce5d 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 82c00e2382..1b9a7f94c1 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 eafa161f51..fcb2ca40c9 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 0000000000..712b489547 --- /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 34e192a034..27f5c396dc 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 1778aa8410..7a95920a74 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 0f42083cb5..a1138c7de8 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 36dac3c86f..d14f57e31e 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 4bfaa93ab7..69a01ae1ac 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 8b211384a0..98e58200fd 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 a4a7b5237d..aa5bec98d5 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 ec6ead79f3..e578a05643 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 86a9cdd3fb..b32d0da8e7 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 138db75ad1..a966ba67ad 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 067ce04114..019d13950a 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 bfc005070e..2a0a1c9c0a 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 ae55f4145a..b82aaa7c21 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 Trigger +#include #include #include @@ -16,8 +17,10 @@ #include #endif #endif - -#include "esp_idf_version.h" +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) +#include "esp_sleep.h" +#endif #include "freertos/FreeRTOS.h" #include @@ -87,6 +90,12 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) { // ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes). const int min_rx_buffer_size = UART_HW_FIFO_LEN(uart_num) + 1; uart_driver_install(uart_num, min_rx_buffer_size, tx_buffer_size, 0, nullptr, 0); +#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \ + (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)) + // Always flush before going to light sleep. Could be disabled for devices + // without TOP_PD or if source_clk = UART_SCLK_RTC + esp_sleep_set_console_uart_handling_mode(ESP_SLEEP_ALWAYS_FLUSH_UART); +#endif } void Logger::pre_setup() { diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index bc3922c436..9a322f751d 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -1,6 +1,8 @@ #ifdef USE_LIBRETINY #include "logger.h" +#include "esphome/components/libretiny/core.h" + namespace esphome::logger { static const char *const TAG = "logger"; @@ -11,18 +13,21 @@ void Logger::pre_setup() { #if LT_HW_UART0 case UART_SELECTION_UART0: this->hw_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 b7884b702b..396728ceaf 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 cb97f5504f..75b567ff89 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 e819766795..0ffaa04493 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 2eec2c586c..f219639eee 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 c3ac90ad11..6c25729bee 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 c2091a6336..51378e4cdd 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 af09282e2d..223528581e 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 382e483d0c..66aaac5301 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -48,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 @@ -61,7 +62,6 @@ from .defines import ( get_focused_widgets, get_lv_images_used, get_refreshed_widgets, - set_widgets_completed, ) from .encoders import ( ENCODERS_CONFIG, @@ -108,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 @@ -238,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( @@ -393,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] @@ -446,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) @@ -457,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, [])) @@ -634,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 26bb433f87..1ef22f9b91 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 95d45de5ea..bab413f04d 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 a62f466413..d797688a7e 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -14,7 +14,7 @@ from esphome.const import ( CONF_TIMEOUT, ) from esphome.core import Lambda -from esphome.cpp_generator import TemplateArguments, get_variable +from esphome.cpp_generator import StaticCastExpression, TemplateArguments, get_variable from esphome.cpp_types import nullptr from .defines import ( @@ -30,7 +30,6 @@ from .defines import ( CONF_SHOW_SNOW, CONF_TOP_LAYER, PARTS, - StaticCastExpression, add_warning, get_focused_widgets, get_options, @@ -79,7 +78,6 @@ from .widgets import ( get_screen_active, get_widgets, set_obj_properties, - wait_for_widgets, ) # Widgets that are used in a focused/refreshed action are tracked in @@ -128,15 +126,7 @@ async def action_to_code( action_id, template_arg, args, - config=None, ): - # Ensure all required ids have been processed, so our LambdaContext doesn't get context-switched. - if config: - for lamb in config.values(): - if isinstance(lamb, Lambda): - for id_ in lamb.requires_ids: - await get_variable(id_) - await wait_for_widgets() async with LambdaContext(parameters=args, where=action_id) as context: for widget in widgets: await action(widget) @@ -146,7 +136,7 @@ async def action_to_code( async def update_to_code(config, action_id, template_arg, args): async def do_update(widget: Widget): await set_obj_properties(widget, config) - await widget.type.to_code(widget, config) + await widget.type.update_to_code(widget, config) if ( widget.type.w_type.value_property is not None and widget.type.w_type.value_property in config @@ -154,9 +144,7 @@ async def update_to_code(config, action_id, template_arg, args): lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr) widgets = await get_widgets(config[CONF_ID]) - return await action_to_code( - widgets, do_update, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_update, action_id, template_arg, args) @automation.register_condition( @@ -508,9 +496,7 @@ async def obj_update_to_code(config, action_id, template_arg, args): await set_obj_properties(widget, config) widgets = await get_widgets(config[CONF_ID]) - return await action_to_code( - widgets, do_update, action_id, template_arg, args, config - ) + return await action_to_code(widgets, do_update, action_id, template_arg, args) def validate_refresh_config(config): diff --git a/esphome/components/lvgl/binary_sensor/__init__.py b/esphome/components/lvgl/binary_sensor/__init__.py index aa68e76421..fc7317f8b9 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 1eee8041f9..c019dff96e 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -10,12 +10,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import ( - CallExpression, - LambdaExpression, - MockObj, - MockObjClass, -) +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import Expression, SafeExpType @@ -25,21 +20,40 @@ DOMAIN = "lvgl" KEY_COLOR_FORMATS = "color_formats" KEY_ESPHOME_FONTS_USED = "esphome_fonts_used" KEY_FOCUSED_WIDGETS = "focused_widgets" +KEY_LIST_TRIGGERS = "list_triggers" KEY_LV_DEFINES = "lv_defines" KEY_LV_FONTS_USED = "lv_fonts_used" KEY_LV_IMAGES_USED = "lv_images_used" KEY_LV_USES = "lv_uses" KEY_NAMED_STYLES = "named_styles" +KEY_OPTIONS = "options" KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" KEY_STYLES_USED = "styles_used" KEY_THEME_UPDATE_REQUESTS = "theme_update_requests" -KEY_THEME_WIDGET_MAP = "theme_widget_map" +KEY_THEME_STYLES = "theme_styles" KEY_UPDATED_WIDGETS = "updated_widgets" -KEY_WIDGET_MAP = "widget_map" -KEY_WIDGETS_COMPLETED = "widgets_completed" -KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" +KEY_WIDGET_MAP = "widget_map" +KEY_WIDGET_THEME_STYLES = "widget_theme_styles" +KEY_DEBUG_OUTLINE_COUNT = "debug_outline_count" + +# Colours for the debug outline, in (red, green, blue) order. They are picked to stay +# distinct from each other and to show up on both light and dark backgrounds. +DEBUG_OUTLINE_COLORS = ( + (255, 0, 0), + (0, 160, 0), + (0, 0, 255), + (255, 140, 0), + (200, 0, 200), + (0, 190, 190), + (160, 100, 0), + (255, 0, 120), + (110, 110, 110), + (140, 200, 0), + (0, 110, 255), + (130, 0, 255), +) # Initial set of LVGL features that are always enabled. _INITIAL_LV_USES = frozenset( @@ -107,6 +121,15 @@ def get_options() -> dict[str, Any]: return _get_data(KEY_OPTIONS, {}) +def next_debug_outline_color() -> tuple[int, int, int]: + """Return the next debug outline colour, cycling through the palette.""" + # A one-element list so that the count can be updated in place. + count = _get_data(KEY_DEBUG_OUTLINE_COUNT, [0]) + color = DEBUG_OUTLINE_COLORS[count[0] % len(DEBUG_OUTLINE_COLORS)] + count[0] += 1 + return color + + def get_defines() -> dict[str, str]: return _get_data(KEY_LV_DEFINES, {}) @@ -115,8 +138,14 @@ def get_updated_widgets() -> dict: return _get_data(KEY_UPDATED_WIDGETS, {}) -def get_theme_widget_map() -> dict[str, Any]: - return _get_data(KEY_THEME_WIDGET_MAP, {}) +def get_theme_styles() -> dict[str, MockObj]: + """Get a map of already created theme style names to their corresponding style IDs.""" + return _get_data(KEY_THEME_STYLES, {}) + + +def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]: + """Get the map of widget type names to the list of (style variable, part/state name)""" + return _get_data(KEY_WIDGET_THEME_STYLES, {}) def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]: @@ -135,20 +164,6 @@ def get_widget_map() -> dict[str, Any]: return _get_data(KEY_WIDGET_MAP, {}) -def get_widgets_completed() -> bool: - # ``[value]`` rather than the bare value so that we can mutate the - # entry in place; ``CORE.data`` is reset for us between runs. - return _get_data(KEY_WIDGETS_COMPLETED, [False])[0] - - -def set_widgets_completed(value: bool) -> None: - _get_data(KEY_WIDGETS_COMPLETED, [False])[0] = value - - -def is_widget_completed(name: ID) -> bool: - return name in get_widget_map() - - def get_focused_widgets() -> set: return _get_data(KEY_FOCUSED_WIDGETS, set()) @@ -157,15 +172,8 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) -class StaticCastExpression(Expression): - __slots__ = ("type", "exp") - - def __init__(self, type: Any, exp: SafeExpType): - self.type = str(type) - self.exp = cg.safe_exp(exp) - - def __str__(self): - return f"static_cast<{self.type}>({self.exp})" +def get_list_triggers() -> dict: + return _get_data(KEY_LIST_TRIGGERS, {}) def add_define(macro: str, value="1"): @@ -192,31 +200,6 @@ def addr(arg) -> MockObj: return MockObj(f"&{arg}") -def call_lambda(lamb: LambdaExpression) -> Expression: - """ - Given a lambda, either reduce to a simple expression or call it, possibly with parameters - from the surrounding context - :param lamb: - :return: - """ - expr = lamb.content.strip() - if expr.startswith("return") and expr.endswith(";"): - # Convert a lambda returning a simple expression to just that expression - expr = cg.RawExpression(expr[6:-1].strip()) - # Don't cast if the return type is a class - if isinstance(lamb.return_type, MockObjClass): - return expr - return StaticCastExpression(lamb.return_type, expr) - # If lambda has parameters, call it with their names - # Parameter names come from hardcoded component code (like "x", "it", "event") - # not from user input, so they're safe to use directly - if lamb.parameters and lamb.parameters.parameters: - return CallExpression( - lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] - ) - return CallExpression(lamb) - - class LValidator: """ A validator for a particular type used in LVGL. Usable in configs as a validator, also @@ -721,11 +704,11 @@ CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" CONF_CLOSE_BUTTON = "close_button" -CONF_COLOR_DEPTH = "color_depth" CONF_COLOR_END = "color_end" CONF_COLOR_START = "color_start" CONF_CONTAINER = "container" CONF_CONTROL = "control" +CONF_DEBUG_OUTLINE = "debug_outline" CONF_DEFAULT_FONT = "default_font" CONF_DEFAULT_GROUP = "default_group" CONF_DIR = "dir" @@ -808,7 +791,6 @@ CONF_RESUME_ON_INPUT = "resume_on_input" CONF_RIGHT_BUTTON = "right_button" CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" -CONF_ROWS = "rows" CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" @@ -884,7 +866,7 @@ LV_SCALE_MODE = LvConstant( DEFAULT_ESPHOME_FONT = "esphome_lv_default_font" -def join_enums(enums, prefix=""): +def join_enums(enums: tuple[str], prefix: str = "") -> MockObj: enums = list(enums) enums.sort() # If a prefix is provided, prepend each constant with the prefix, and assume that all the constants are within the @@ -894,6 +876,19 @@ def join_enums(enums, prefix=""): return literal("|".join(f"(int){e.upper()}" for e in enums)) +def get_part_state_selector(part: str, state: str) -> MockObj: + """Combine a part and state into a single selector value, e.g. LV_PART_KNOB | LV_STATE_PRESSED.""" + state = "LV_STATE_" + state.removeprefix("LV_STATE_").upper() + part = "LV_PART_" + part.removeprefix("LV_PART_").upper() + if state == "LV_STATE_DEFAULT": + return literal(part) + if part == "LV_PART_MAIN": + return literal(state) + return MockObj( + StaticCastExpression("lv_style_selector_t", literal(state)) + ) | MockObj(StaticCastExpression("lv_style_selector_t", literal(part))) + + # fmt: off LV_COLOR_FORMATS = ( "RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1", diff --git a/esphome/components/lvgl/light/__init__.py b/esphome/components/lvgl/light/__init__.py index dcdf67a520..0a8720f5ee 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 42352b9602..961fa4079d 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -4,6 +4,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import image from esphome.components.color import CONF_HEX, ColorStruct, from_rgbw +from esphome.components.const.css_colors import CSS_COLORS from esphome.components.font import Font from esphome.components.image import Image_ import esphome.config_validation as cv @@ -16,7 +17,7 @@ from esphome.const import ( CONF_VALUE, ) from esphome.core import CORE, ID, Lambda -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.cpp_types import ESPTime, int32, uint32 from esphome.helpers import cpp_string_escape from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -33,9 +34,7 @@ from .defines import ( LV_FONTS, LValidator, LvConstant, - StaticCastExpression, add_lv_use, - call_lambda, get_esphome_fonts_used, get_lv_fonts_used, get_lv_images_used, @@ -66,170 +65,19 @@ opacity = LValidator( animatable=True, ) -COLOR_NAMES = { - "aliceblue": 0xF0F8FF, - "antiquewhite": 0xFAEBD7, - "aqua": 0x00FFFF, - "aquamarine": 0x7FFFD4, - "azure": 0xF0FFFF, - "beige": 0xF5F5DC, - "bisque": 0xFFE4C4, - "black": 0x000000, - "blanchedalmond": 0xFFEBCD, - "blue": 0x0000FF, - "blueviolet": 0x8A2BE2, - "brown": 0xA52A2A, - "burlywood": 0xDEB887, - "cadetblue": 0x5F9EA0, - "chartreuse": 0x7FFF00, - "chocolate": 0xD2691E, - "coral": 0xFF7F50, - "cornflowerblue": 0x6495ED, - "cornsilk": 0xFFF8DC, - "crimson": 0xDC143C, - "cyan": 0x00FFFF, - "darkblue": 0x00008B, - "darkcyan": 0x008B8B, - "darkgoldenrod": 0xB8860B, - "darkgray": 0xA9A9A9, - "darkgreen": 0x006400, - "darkgrey": 0xA9A9A9, - "darkkhaki": 0xBDB76B, - "darkmagenta": 0x8B008B, - "darkolivegreen": 0x556B2F, - "darkorange": 0xFF8C00, - "darkorchid": 0x9932CC, - "darkred": 0x8B0000, - "darksalmon": 0xE9967A, - "darkseagreen": 0x8FBC8F, - "darkslateblue": 0x483D8B, - "darkslategray": 0x2F4F4F, - "darkslategrey": 0x2F4F4F, - "darkturquoise": 0x00CED1, - "darkviolet": 0x9400D3, - "deeppink": 0xFF1493, - "deepskyblue": 0x00BFFF, - "dimgray": 0x696969, - "dimgrey": 0x696969, - "dodgerblue": 0x1E90FF, - "firebrick": 0xB22222, - "floralwhite": 0xFFFAF0, - "forestgreen": 0x228B22, - "fuchsia": 0xFF00FF, - "gainsboro": 0xDCDCDC, - "ghostwhite": 0xF8F8FF, - "goldenrod": 0xDAA520, - "gold": 0xFFD700, - "gray": 0x808080, - "green": 0x008000, - "greenyellow": 0xADFF2F, - "grey": 0x808080, - "honeydew": 0xF0FFF0, - "hotpink": 0xFF69B4, - "indianred": 0xCD5C5C, - "indigo": 0x4B0082, - "ivory": 0xFFFFF0, - "khaki": 0xF0E68C, - "lavenderblush": 0xFFF0F5, - "lavender": 0xE6E6FA, - "lawngreen": 0x7CFC00, - "lemonchiffon": 0xFFFACD, - "lightblue": 0xADD8E6, - "lightcoral": 0xF08080, - "lightcyan": 0xE0FFFF, - "lightgoldenrodyellow": 0xFAFAD2, - "lightgray": 0xD3D3D3, - "lightgreen": 0x90EE90, - "lightgrey": 0xD3D3D3, - "lightpink": 0xFFB6C1, - "lightsalmon": 0xFFA07A, - "lightseagreen": 0x20B2AA, - "lightskyblue": 0x87CEFA, - "lightslategray": 0x778899, - "lightslategrey": 0x778899, - "lightsteelblue": 0xB0C4DE, - "lightyellow": 0xFFFFE0, - "lime": 0x00FF00, - "limegreen": 0x32CD32, - "linen": 0xFAF0E6, - "magenta": 0xFF00FF, - "maroon": 0x800000, - "mediumaquamarine": 0x66CDAA, - "mediumblue": 0x0000CD, - "mediumorchid": 0xBA55D3, - "mediumpurple": 0x9370DB, - "mediumseagreen": 0x3CB371, - "mediumslateblue": 0x7B68EE, - "mediumspringgreen": 0x00FA9A, - "mediumturquoise": 0x48D1CC, - "mediumvioletred": 0xC71585, - "midnightblue": 0x191970, - "mintcream": 0xF5FFFA, - "mistyrose": 0xFFE4E1, - "moccasin": 0xFFE4B5, - "navajowhite": 0xFFDEAD, - "navy": 0x000080, - "oldlace": 0xFDF5E6, - "olive": 0x808000, - "olivedrab": 0x6B8E23, - "orange": 0xFFA500, - "orangered": 0xFF4500, - "orchid": 0xDA70D6, - "palegoldenrod": 0xEEE8AA, - "palegreen": 0x98FB98, - "paleturquoise": 0xAFEEEE, - "palevioletred": 0xDB7093, - "papayawhip": 0xFFEFD5, - "peachpuff": 0xFFDAB9, - "peru": 0xCD853F, - "pink": 0xFFC0CB, - "plum": 0xDDA0DD, - "powderblue": 0xB0E0E6, - "purple": 0x800080, - "rebeccapurple": 0x663399, - "red": 0xFF0000, - "rosybrown": 0xBC8F8F, - "royalblue": 0x4169E1, - "saddlebrown": 0x8B4513, - "salmon": 0xFA8072, - "sandybrown": 0xF4A460, - "seagreen": 0x2E8B57, - "seashell": 0xFFF5EE, - "sienna": 0xA0522D, - "silver": 0xC0C0C0, - "skyblue": 0x87CEEB, - "slateblue": 0x6A5ACD, - "slategray": 0x708090, - "slategrey": 0x708090, - "snow": 0xFFFAFA, - "springgreen": 0x00FF7F, - "steelblue": 0x4682B4, - "tan": 0xD2B48C, - "teal": 0x008080, - "thistle": 0xD8BFD8, - "tomato": 0xFF6347, - "turquoise": 0x40E0D0, - "violet": 0xEE82EE, - "wheat": 0xF5DEB3, - "white": 0xFFFFFF, - "whitesmoke": 0xF5F5F5, - "yellow": 0xFFFF00, - "yellowgreen": 0x9ACD32, -} - @schema_extractor("one_of") def color(value): if value == SCHEMA_EXTRACT: return ["hex color value", "color ID"] - return cv.Any(cv.int_, cv.one_of(*COLOR_NAMES, lower=True), cv.use_id(ColorStruct))( + return cv.Any(cv.int_, cv.one_of(*CSS_COLORS, lower=True), cv.use_id(ColorStruct))( value ) def get_component_colors(value): - if isinstance(value, str) and value in COLOR_NAMES: - value = COLOR_NAMES[value] + if isinstance(value, str) and value in CSS_COLORS: + value = CSS_COLORS[value] if isinstance(value, int): return value >> 16, value >> 8 & 0xFF, value & 0xFF if isinstance(value, ID): @@ -256,8 +104,8 @@ class LvColor(LValidator): ) def __getattr__(self, item): - if item in COLOR_NAMES: - return color_retmapper(COLOR_NAMES[item]) + if item in CSS_COLORS: + return color_retmapper(CSS_COLORS[item]) raise AttributeError(item) @@ -309,10 +157,12 @@ padding = LValidator(padding_validator, int32, retmapper=literal) def scale_validator(value): - return cv.float_range(0.1, 10.0)(value) + return cv.float_range(0.0, 10.0)(value) -scale = LValidator(scale_validator, uint32, retmapper=lambda x: int(x * 256)) +scale = LValidator( + scale_validator, uint32, retmapper=lambda x: int(x * 256), animatable=True +) def angle(value): diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 850b63a26f..feffac28b3 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 2c988473a9..466d400023 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 be51963ba1..0d8d99ab21 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 bbc977dca5..cf766b96c8 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 e69ea9771a..c5ef98b05e 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 ad42028327..1fef37e2ee 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 509e4f42ad..907665eca6 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 61db5444e8..3d5e0305b9 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 c3306ad57a..f0575fb101 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 c9099e3c3a..d69e90056f 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -4,7 +4,7 @@ from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action -from esphome.config_validation import Invalid, Schema +from esphome.config_validation import Schema from esphome.const import ( CONF_DEFAULT, CONF_GROUP, @@ -16,11 +16,12 @@ from esphome.const import ( ) from esphome.core import ID, EsphomeError, TimePeriod from esphome.coroutine import FakeAwaitable -from esphome.cpp_generator import MockObj +from esphome.cpp_generator import MockObj, call_lambda from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( + CONF_DEBUG_OUTLINE, CONF_FLEX_ALIGN_CROSS, CONF_FLEX_ALIGN_MAIN, CONF_FLEX_ALIGN_TRACK, @@ -42,13 +43,13 @@ from ..defines import ( STATES, LValidator, add_lv_use, - call_lambda, + get_options, + get_part_state_selector, get_styles_used, - get_theme_widget_map, get_widget_map, - get_widgets_completed, join_enums, literal, + next_debug_outline_color, ) from ..lv_validation import lv_int from ..lvcode import ( @@ -192,17 +193,26 @@ class WidgetType: w = Widget.create(wid, var, self, config) apply_theme_styles(w) await set_obj_properties(w, config) + apply_debug_outline(w) await add_widgets(w, config) await self.to_code(w, config) return w - async def to_code(self, w: "Widget", config: dict): + async def to_code(self, w: "Widget", config: dict) -> None: """ - Update a widget, also called when creating + Generate code for widget properties and actions. :param config: :return: """ + async def update_to_code(self, w: "Widget", config: dict) -> None: + """ + Update a widget. Defaults to calling to_code, but can be overridden + :param w: The widget to update + :param config: The configuration for the update + """ + await self.to_code(w, config) + async def obj_creator(self, parent: MockObj, config: dict): """ Create an instance of the widget type @@ -258,17 +268,25 @@ class WidgetType: def apply_theme_styles(w: "Widget") -> None: """Apply the current theme's styles for this widget's type""" - for part, states in get_theme_widget_map().get(w.type.name, {}).items(): - part = "LV_PART_" + part.upper() - for state, style in states.items(): - state = "LV_STATE_" + state.upper() - if state == "LV_STATE_DEFAULT": - lv_state = literal(part) - elif part == "LV_PART_MAIN": - lv_state = literal(state) - else: - lv_state = join_enums((state, part)) - w.add_style(style, lv_state) + from ..styles import get_widget_theme_styles + + for style, lv_state in get_widget_theme_styles(w.type.name): + w.add_style(style, lv_state) + + +def apply_debug_outline(w: "Widget") -> None: + """ + When `debug_outline` is set, outline this widget in the next palette colour. + An outline is drawn outside the widget's own box, so it doesn't take up layout + space and doesn't touch the widget's own `border_*` style, unlike a border. + """ + if not get_options().get(CONF_DEBUG_OUTLINE): + return + r, g, b = next_debug_outline_color() + w.set_style("outline_width", 1) + w.set_style("outline_pad", 0) + w.set_style("outline_color", f"lv_color_make({r}, {g}, {b})") + w.set_style("outline_opa", "LV_OPA_COVER") class Widget: @@ -324,8 +342,6 @@ class Widget: return lv_obj.remove_flag(self.obj, literal(flag)) def add_style(self, style_id, state=LV_STATE.DEFAULT): - if "|" in state: - state = f"(lv_state_t)({state})" lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( @@ -458,10 +474,6 @@ def get_widget_generator(wid): while True: if obj := widget_map.get(wid): return obj - if get_widgets_completed(): - raise Invalid( - f"Widget {wid} not found, yet all widgets should be defined by now" - ) yield @@ -471,19 +483,6 @@ async def get_widget_(wid): return await FakeAwaitable(get_widget_generator(wid)) -def widgets_wait_generator(): - while True: - if get_widgets_completed(): - return - yield - - -async def wait_for_widgets(): - if get_widgets_completed(): - return - await FakeAwaitable(widgets_wait_generator()) - - async def get_widgets(config: dict | list, id: str = CONF_ID) -> list[Widget]: if not config: return [] @@ -616,15 +615,8 @@ async def set_obj_properties(w: Widget, config): _set_layout_options(w, layout, base_name) parts = collect_parts(config) for part, states in parts.items(): - part = "LV_PART_" + part.upper() for state, props in states.items(): - state = "LV_STATE_" + state.upper() - if state == "LV_STATE_DEFAULT": - lv_state = literal(part) - elif part == "LV_PART_MAIN": - lv_state = literal(state) - else: - lv_state = join_enums((state, part)) + lv_state = get_part_state_selector(part, state) for style_id in props.get(CONF_STYLES, ()): w.add_style(style_id, lv_state) for prop, value in { diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index 0ad512cd8b..306f692501 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 02dc9ed4ba..854c5e3127 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 4427a3b00e..2137acb3f6 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 65516513a6..0356e66dac 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 7711e8bfe4..f41cb8e4e9 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 166e88f382..ba32288128 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 7e75ab6a2d..9dc9123ac9 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 f000ea1846..019c6d4eca 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 2e43eaf7e2..f9b744daf0 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 3b71b50fd8..97641ac2d3 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 8c9acc8e0c..2384691e32 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 6b19e5bd5e..0000000000 --- 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 67fb8aa5b7..1b621057c6 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 a52f45a18f..3d2a83f8cd 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/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index 6b8bdc8de5..731f584056 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -134,6 +134,4 @@ void MAX44009Sensor::write_(uint8_t reg, uint8_t value) { } } -void MAX44009Sensor::set_mode(MAX44009Mode mode) { this->mode_ = mode; } - } // namespace esphome::max44009 diff --git a/esphome/components/max44009/max44009.h b/esphome/components/max44009/max44009.h index b62aed7a56..5eb1555350 100644 --- a/esphome/components/max44009/max44009.h +++ b/esphome/components/max44009/max44009.h @@ -16,7 +16,7 @@ class MAX44009Sensor final : public sensor::Sensor, public PollingComponent, pub void setup() override; void dump_config() override; void update() override; - void set_mode(MAX44009Mode mode); + void set_mode(MAX44009Mode mode) { this->mode_ = mode; } bool set_continuous_mode(); bool set_low_power_mode(); diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 5e45d71899..05abaf886f 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 f1db2e3240..0000000000 --- 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 f06dfd5087..0000000000 --- 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 54711263dd..7974ef2020 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 9332274a95..e6309b21cc 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 5f4b7276d8..aa606507f0 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 755d86e4ea..a626835518 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 33abbe847a..ef8b568453 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 db1a1e6a29..b95a9e1510 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 4be317b2f8..0000000000 --- a/esphome/components/mcp4461/output/automation.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "mcp4461_output.h" - -namespace esphome::mcp4461 { - -template class WiperIncreaseAction : public Action { - public: - explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->increase_wiper(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -template class WiperDecreaseAction : public Action { - public: - explicit WiperDecreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->decrease_wiper(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -// Persist the current level to the chip's nonvolatile register immediately — useful with -// nonvolatile: false to persist only at deliberate moments (e.g. on a button press), or to -// bypass the stability delay of the automatic mirroring. -template class WiperStoreNonvolatileAction : public Action { - public: - explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {} - void play(Ts... x) override { this->wiper_->store_nonvolatile(); } - - protected: - Mcp4461Wiper *wiper_; -}; - -template class WiperSetTerminalAction : public Action { - public: - WiperSetTerminalAction(Mcp4461Wiper *wiper, char terminal, bool enable) - : wiper_(wiper), terminal_(terminal), enable_(enable) {} - void play(Ts... x) override { - if (this->enable_) { - this->wiper_->enable_terminal(this->terminal_); - } else { - this->wiper_->disable_terminal(this->terminal_); - } - } - - protected: - Mcp4461Wiper *wiper_; - char terminal_; - bool enable_; -}; - -} // namespace esphome::mcp4461 diff --git a/esphome/components/mcp4461/output/mcp4461_output.cpp b/esphome/components/mcp4461/output/mcp4461_output.cpp index 5c373ddc7d..d38eed4d09 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -38,14 +38,6 @@ float Mcp4461Wiper::update_state() { return this->state_; } -void Mcp4461Wiper::set_state(bool state) { - if (state) { - this->turn_on(); - } else { - this->turn_off(); - } -} - void Mcp4461Wiper::turn_on() { this->parent_->enable_wiper_(this->wiper_); } void Mcp4461Wiper::turn_off() { this->parent_->disable_wiper_(this->wiper_); } diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index c8d1ef1ec5..f0af6037e7 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -13,9 +13,6 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parentedenable_terminal(terminal); + } else { + this->disable_terminal(terminal); + } + } /// @brief Immediately persist the current wiper level to the chip's nonvolatile register /// (independent of the deferred nonvolatile mirroring / its stability delay) void store_nonvolatile(); diff --git a/esphome/components/mcp9600/sensor.py b/esphome/components/mcp9600/sensor.py index 5542ffaa6c..75f2e025c7 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/__init__.py b/esphome/components/mdns/__init__.py index c8020104b3..0fb24fdf1d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, CONF_ID, + CONF_MDNS, + CONF_OPENTHREAD, CONF_PORT, CONF_PROTOCOL, CONF_SERVICE, @@ -184,6 +186,28 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def request_service_enable_disable() -> bool: + """Request MDNSComponent::set_service_enabled() support. + + ESP32 only, not with OpenThread. Returns True when the + USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. + + Public API for external components. Do not remove. + """ + mdns_config = CORE.config.get(CONF_MDNS) + if ( + mdns_config is None + or mdns_config[CONF_DISABLED] + or not CORE.is_esp32 + or CONF_OPENTHREAD in CORE.config + ): + return False + cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") + # Services must stay stored so a disabled service can be re-registered + enable_mdns_storage() + return True + + @coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config: ConfigType) -> None: if config[CONF_DISABLED] is True: diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index fa39e86ed0..4f236153c2 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -118,6 +118,9 @@ void MDNSComponent::compile_records_(StaticVector uint16_t { return USE_SENDSPIN_PORT; }; sendspin_service.txt_records = {{MDNS_STR(TXT_SENDSPIN_PATH), MDNS_STR(VALUE_SENDSPIN_PATH)}}; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Starts disabled; the sendspin hub enables it once its server is running + sendspin_service.enabled = false; +#endif #endif #ifdef USE_WEBSERVER diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 4f97e8cb99..93d9eb1e38 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,6 +5,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" // On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by // IP state listener events on whichever network interface is configured. #if (defined(USE_ESP8266) || defined(USE_RP2)) && \ @@ -39,12 +40,7 @@ struct MDNSString; // Macro to cast string literals to MDNSString* (works on all platforms) #define MDNS_STR(name) (reinterpret_cast(name)) -#ifdef USE_ESP8266 -#include -#define MDNS_STR_ARG(s) ((PGM_P) (s)) -#else -#define MDNS_STR_ARG(s) (reinterpret_cast(s)) -#endif +#define MDNS_STR_ARG(s) (reinterpret_cast(s)) // Service count is calculated at compile time by Python codegen // MDNS_SERVICE_COUNT will always be defined @@ -63,6 +59,9 @@ struct MDNSService { const MDNSString *proto; TemplatableFn port; FixedVector txt_records; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool enabled{true}; +#endif }; class MDNSComponent final : public Component @@ -112,6 +111,19 @@ class MDNSComponent final : public Component const StaticVector &get_services() const { return this->services_; } #endif +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifndef USE_MDNS_STORE_SERVICES +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" +#endif +#ifdef USE_OPENTHREAD +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#endif + /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). + /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. + /// Returns true if the service is in the requested state afterwards. Blocks briefly on the mDNS task. + bool set_service_enabled(const char *service_type, const char *proto, bool enabled); +#endif + void on_shutdown() override; #ifdef USE_MDNS_DYNAMIC_TXT diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 17000a2bd7..48df61326e 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ESP32) && defined(USE_MDNS) #include +#include #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -11,6 +12,23 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; +#ifndef USE_OPENTHREAD +static esp_err_t add_service(const MDNSService &service) { + // Stack buffer for up to 16 txt records, heap fallback for more + SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); + for (size_t i = 0; i < service.txt_records.size(); i++) { + const auto &record = service.txt_records[i]; + // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ + // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies + txt_records.get()[i].key = MDNS_STR_ARG(record.key); + txt_records.get()[i].value = MDNS_STR_ARG(record.value); + } + uint16_t port = service.port.value(); + return mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, + txt_records.get(), service.txt_records.size()); +} +#endif + static void register_esp32(MDNSComponent *comp, StaticVector &services) { #ifdef USE_OPENTHREAD // OpenThread handles service registration via SRP client @@ -27,27 +45,50 @@ static void register_esp32(MDNSComponent *comp, StaticVector txt_records(service.txt_records.size()); - for (size_t i = 0; i < service.txt_records.size(); i++) { - const auto &record = service.txt_records[i]; - // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ - // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies - txt_records.get()[i].key = MDNS_STR_ARG(record.key); - txt_records.get()[i].value = MDNS_STR_ARG(record.value); - } - uint16_t port = service.port.value(); - err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, - txt_records.get(), service.txt_records.size()); - + for (auto &service : services) { +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + if (!service.enabled) + continue; +#endif + err = add_service(service); if (err != ESP_OK) { ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err)); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Let a later enable call retry + service.enabled = false; +#endif } } #endif } +#if defined(USE_MDNS_SUPPORTS_ENABLE_DISABLE) && !defined(USE_OPENTHREAD) +bool MDNSComponent::set_service_enabled(const char *service_type, const char *proto, bool enabled) { + // services_ is compiled in setup() + if (!this->is_ready()) { + ESP_LOGW(TAG, "Cannot %s service %s before setup", enabled ? "enable" : "disable", service_type); + return false; + } + for (auto &service : this->services_) { + if (strcmp(MDNS_STR_ARG(service.service_type), service_type) != 0 || + strcmp(MDNS_STR_ARG(service.proto), proto) != 0) { + continue; + } + if (service.enabled == enabled) + return true; + esp_err_t err = enabled ? add_service(service) : mdns_service_remove(service_type, proto); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to %s service %s: %s", enabled ? "enable" : "disable", service_type, esp_err_to_name(err)); + return false; + } + service.enabled = enabled; + return true; + } + ESP_LOGW(TAG, "Service %s not found", service_type); + return false; +} +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD + void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index aa1e88dca9..d99e7877bf 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 899acfefdf..e4bb365c82 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 3cef3a3930..0aacdda888 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 5852686608..053f17765e 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 092c4977ce..f04f784ebc 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 59dfc624fa..0000000000 --- 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 a7326ab77a..c235e290ec 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 9a3f5b43e7..c604f64d77 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 c28616a290..0fac18d828 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 d7a3352432..770399fef5 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 9572ec6c65..0000000000 --- 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 0e03bca233..da952ab87b 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components import climate, remote_transmitter, sensor, uart +from esphome.components import climate, remote_base, remote_transmitter, sensor, uart from esphome.components.climate import ClimateMode, ClimatePreset, ClimateSwingMode from esphome.components.remote_base import CONF_TRANSMITTER_ID import esphome.config_validation as cv @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT, ) -from esphome.core import CORE, coroutine +from esphome.core import CORE CODEOWNERS = ["@dudanov"] DEPENDENCIES = ["climate", "uart"] @@ -53,25 +53,6 @@ def templatize(value): return cv.Schema(ret) -def register_action(name, type_, schema): - validator = templatize(schema).extend(MIDEA_ACTION_BASE_SCHEMA) - registerer = automation.register_action( - f"midea_ac.{name}", type_, validator, synchronous=True - ) - - def decorator(func): - async def new_func(config, action_id, template_arg, args): - ac_ = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg) - cg.add(var.set_parent(ac_)) - await coroutine(func)(var, config, args) - return var - - return registerer(new_func) - - return decorator - - ALLOWED_CLIMATE_MODES = { "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, "COOL": ClimateMode.CLIMATE_MODE_COOL, @@ -161,16 +142,6 @@ CONFIG_SCHEMA = cv.All( ), ) -# Actions -FollowMeAction = midea_ac_ns.class_("FollowMeAction", automation.Action) -DisplayToggleAction = midea_ac_ns.class_("DisplayToggleAction", automation.Action) -SwingStepAction = midea_ac_ns.class_("SwingStepAction", automation.Action) -BeeperOnAction = midea_ac_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = midea_ac_ns.class_("BeeperOffAction", automation.Action) -PowerOnAction = midea_ac_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = midea_ac_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = midea_ac_ns.class_("PowerToggleAction", automation.Action) - MIDEA_ACTION_BASE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(AirConditioner), @@ -187,84 +158,31 @@ MIDEA_FOLLOW_ME_SCHEMA = cv.Schema( ) -@register_action("follow_me", FollowMeAction, MIDEA_FOLLOW_ME_SCHEMA) -async def follow_me_to_code(var, config, args): - template_ = await cg.templatable(config[CONF_BEEPER], args, cg.bool_) - cg.add(var.set_beeper(template_)) - template_ = await cg.templatable(config[CONF_USE_FAHRENHEIT], args, cg.bool_) - cg.add(var.set_use_fahrenheit(template_)) - template_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) - cg.add(var.set_temperature(template_)) - - -# Toggle Display action -@register_action( - "display_toggle", - DisplayToggleAction, - cv.Schema({}), +automation.register_apply_action( + "midea_ac.follow_me", + templatize(MIDEA_FOLLOW_ME_SCHEMA).extend(MIDEA_ACTION_BASE_SCHEMA), + automation.ApplyCall( + "do_follow_me({}, {}, {})", + ( + (CONF_TEMPERATURE, cg.float_), + (CONF_USE_FAHRENHEIT, cg.bool_), + (CONF_BEEPER, cg.bool_), + ), + ), ) -async def display_toggle_to_code(var, config, args): - pass - -# Swing Step action -@register_action( - "swing_step", - SwingStepAction, - cv.Schema({}), -) -async def swing_step_to_code(var, config, args): - pass - - -# Beeper On action -@register_action( - "beeper_on", - BeeperOnAction, - cv.Schema({}), -) -async def beeper_on_to_code(var, config, args): - pass - - -# Beeper Off action -@register_action( - "beeper_off", - BeeperOffAction, - cv.Schema({}), -) -async def beeper_off_to_code(var, config, args): - pass - - -# Power On action -@register_action( - "power_on", - PowerOnAction, - cv.Schema({}), -) -async def power_on_to_code(var, config, args): - pass - - -# Power Off action -@register_action( - "power_off", - PowerOffAction, - cv.Schema({}), -) -async def power_off_to_code(var, config, args): - pass - - -# Power Toggle action -@register_action( - "power_toggle", - PowerToggleAction, - cv.Schema({}), -) -async def power_inv_to_code(var, config, args): - pass +for _name, _call in ( + ("midea_ac.display_toggle", "do_display_toggle()"), + ("midea_ac.swing_step", "do_swing_step()"), + ("midea_ac.beeper_on", "do_beeper_on()"), + ("midea_ac.beeper_off", "do_beeper_off()"), + ("midea_ac.power_on", "do_power_on()"), + ("midea_ac.power_off", "do_power_off()"), + ("midea_ac.power_toggle", "do_power_toggle()"), +): + automation.register_apply_action( + _name, MIDEA_ACTION_BASE_SCHEMA, automation.ApplyCall(_call) + ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( @@ -280,6 +198,7 @@ async def to_code(config): cg.add(var.set_response_timeout(config[CONF_TIMEOUT].total_milliseconds)) cg.add(var.set_request_attempts(config[CONF_NUM_ATTEMPTS])) if CONF_TRANSMITTER_ID in config: + remote_base.request_protocol("midea") # ir_transmitter.h uses it from C++ cg.add_define("USE_REMOTE_TRANSMITTER") transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) cg.add(var.set_transmitter(transmitter_)) @@ -295,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/midea_ir/climate.py b/esphome/components/midea_ir/climate.py index 84bfeab0d4..e1b2b56ada 100644 --- a/esphome/components/midea_ir/climate.py +++ b/esphome/components/midea_ir/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base import esphome.config_validation as cv from esphome.const import CONF_USE_FAHRENHEIT from esphome.types import ConfigType @@ -19,5 +19,9 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MideaIR).extend( async def to_code(config: ConfigType) -> None: + # midea_ir uses MideaProtocol from C++ and auto-loads coolix, whose coolix.cpp uses + # CoolixProtocol even when no coolix climate is configured + remote_base.request_protocol("midea") + remote_base.request_protocol("coolix") var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 3f73f96327..526afa914b 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 b23982655a..6bf94bf364 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 0150cc2544..a27be568f2 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 @@ -259,7 +260,7 @@ bool MipiDsi::check_buffer_() { } void MipiDsi::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 7bf2feb73c..4243d3116c 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 5b07229ec7..4e847ef6fd 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 b91528160e..71234c2f25 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 c11044c288..4928b6f667 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); } @@ -259,7 +261,7 @@ bool MipiRgb::check_buffer_() { } void MipiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y) || this->is_failed()) + if (this->is_point_clipped(x, y) || this->is_failed()) return; switch (this->rotation_) { diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index f528943c1b..8b29e273ab 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 cad5dc8e20..b51e7447ad 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 e8b54da5c7..ff49979a24 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 0000000000..aec5d9b4fd --- /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 0000000000..30bbdcd8ee --- /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 ee1264cf23..48a2cafbdd 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)); @@ -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}; }; /** @@ -604,7 +622,7 @@ class MipiSpiBuffer // Draw a pixel at the given coordinates. void draw_pixel_at(int x, int y, Color color) override { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; if constexpr (not HAS_HARDWARE_ROTATION) { if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/mipi_spi/models/spd2010.py b/esphome/components/mipi_spi/models/spd2010.py new file mode 100644 index 0000000000..b5893f179a --- /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 d108ea4621..9cb355fe9c 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 7925b7ce44..269d39b06b 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 470b7be5fc..442869eec5 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 f9ca3a47e6..0000000000 --- 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 05a29b3665..756c8142c2 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 cea76278ab..fc665b079a 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 a3746c019a..44473b12c3 100644 --- a/esphome/components/mixer/speaker/__init__.py +++ b/esphome/components/mixer/speaker/__init__.py @@ -15,9 +15,7 @@ from esphome.const import ( CONF_TIMEOUT, PLATFORM_ESP32, ) -from esphome.core import ID from esphome.core.entity_helpers import inherit_property_from -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -32,10 +30,6 @@ CONF_DECIBEL_REDUCTION = "decibel_reduction" CONF_QUEUE_MODE = "queue_mode" CONF_SOURCE_SPEAKERS = "source_speakers" -DuckingApplyAction = mixer_speaker_ns.class_( - "DuckingApplyAction", automation.Action, cg.Parented.template(SourceSpeaker) -) - SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend( { @@ -148,34 +142,21 @@ async def to_code(config: ConfigType) -> None: cg.add(var.add_source_speaker(source_speaker)) -@automation.register_action( +automation.register_apply_action( "mixer_speaker.apply_ducking", - DuckingApplyAction, cv.Schema( { cv.GenerateID(): cv.use_id(SourceSpeaker), cv.Required(CONF_DECIBEL_REDUCTION): cv.templatable( - cv.int_range(min=0, max=51) + cv.int_range(min=0, max=255) ), cv.Optional(CONF_DURATION, default="0.0s"): cv.templatable( cv.positive_time_period_milliseconds ), } ), - synchronous=True, + automation.ApplyCall( + "apply_ducking({}, {})", + ((CONF_DECIBEL_REDUCTION, cg.uint8), (CONF_DURATION, cg.uint32)), + ), ) -async def ducking_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - decibel_reduction = await cg.templatable( - config[CONF_DECIBEL_REDUCTION], args, cg.uint8 - ) - cg.add(var.set_decibel_reduction(decibel_reduction)) - duration = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(duration)) - return var diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h deleted file mode 100644 index ea51b6b889..0000000000 --- a/esphome/components/mixer/speaker/automation.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "mixer_speaker.h" - -#ifdef USE_ESP32 - -namespace esphome::mixer_speaker { -template class DuckingApplyAction final : public Action, public Parented { - TEMPLATABLE_VALUE(uint8_t, decibel_reduction); - TEMPLATABLE_VALUE(uint32_t, duration); - void play(const Ts &...x) override { - this->parent_->apply_ducking(this->decibel_reduction_.value(x...), this->duration_.value(x...)); - } -}; -} // namespace esphome::mixer_speaker - -#endif diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index 85fb445d40..41b7123269 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -306,9 +306,9 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptraudio_stream_info_.bytes_to_samples(bytes_read); if (samples_to_duck > 0) { - esp_audio_libs::ducking::apply(audio_source->mutable_data(), - static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), - samples_to_duck, this->ducking_state_); + this->ducking_ramp_.process(audio_source->mutable_data(), + static_cast(this->audio_stream_info_.get_bits_per_sample() / 8), + samples_to_duck); } return bytes_read; @@ -316,7 +316,7 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0; - esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples); + this->ducking_ramp_.set_target_db_reduction_over(decibel_reduction, transition_samples); } void SourceSpeaker::enter_stopping_state_() { diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 00e89d1782..04b0cb94f4 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -11,7 +11,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/static_task.h" -#include // esp-audio-libs +#include // esp-audio-libs #include @@ -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::ducking::DuckingState ducking_state_{}; + esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing - 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 0000000000..527c6a1eb2 --- /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 0000000000..9201596af2 --- /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 0000000000..0cdbee1fd9 --- /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 0c0476fb11..f8aa1003bc 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 fc23cf49e8..1a6af4b1b3 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 14fc48a626..92ca6ea898 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 96f1ff5954..5ea07307e4 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 e4da41e384..6ae4f2f0ad 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 0000000000..11689f8bc7 --- /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 0000000000..4aea2ef7fc --- /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 0000000000..590c67005c --- /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 59bdffc114..4ff42c7d9c 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/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 2d3b6631bc..508b3743d1 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -26,44 +26,129 @@ static const uint8_t MLX90614_ID4 = 0x3F; static const char *const TAG = "mlx90614"; +// The EEPROM cell has a limited number of write cycles, so stop retrying after a few failures +static constexpr uint8_t EMISSIVITY_WRITE_ATTEMPTS = 3; + +// SMBus packet error code: CRC-8 with polynomial 0x07, MSB first +static uint8_t crc8_pec(const uint8_t *data, uint8_t len) { return crc8(data, len, 0x00, 0x07, true); } + void MLX90614Component::setup() { - if (!this->write_emissivity_()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); - this->mark_failed(); + if (std::isnan(this->emissivity_)) { return; } + this->emissivity_write_attempts_ = EMISSIVITY_WRITE_ATTEMPTS; + this->try_write_emissivity_(); + if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } +} + +void MLX90614Component::try_write_emissivity_() { + if (this->emissivity_write_attempts_ == 0) { + return; + } + if (this->write_emissivity_()) { + this->emissivity_write_attempts_ = 0; + return; + } + if (--this->emissivity_write_attempts_ == 0) { + ESP_LOGE(TAG, "Giving up on writing emissivity after %u attempts", EMISSIVITY_WRITE_ATTEMPTS); + this->emissivity_write_failed_ = true; + } } bool MLX90614Component::write_emissivity_() { - if (std::isnan(this->emissivity_)) + // Skip the write when the EEPROM already holds the desired value to save write cycles + uint16_t current_emissivity; + if (this->read_register_(MLX90614_EMISSIVITY, current_emissivity) != i2c::ERROR_OK) { + return false; + } + + const auto desired_emissivity = static_cast(this->emissivity_ * 0xFFFF); + if (current_emissivity == desired_emissivity) { return true; - uint16_t value = (uint16_t) (this->emissivity_ * 65535); - if (!this->write_bytes_(MLX90614_EMISSIVITY, 0)) { - return false; } - delay(10); - if (!this->write_bytes_(MLX90614_EMISSIVITY, value)) { - return false; - } - delay(10); - return true; + + return this->write_register_(MLX90614_EMISSIVITY, desired_emissivity); } -bool MLX90614Component::write_bytes_(uint8_t reg, uint16_t data) { +bool MLX90614Component::write_register_(uint8_t reg, uint16_t data) { + // The PEC covers the whole write transaction: SLA+W, command, data low, data high uint8_t buf[5]; buf[0] = this->address_ << 1; buf[1] = reg; - buf[2] = data & 0xFF; - buf[3] = data >> 8; - buf[4] = crc8(buf, 4, 0x00, 0x07, true); - return this->write_bytes(reg, buf + 2, 3); + + // See datasheet 8.3.3.1 EEPROM write sequence + // 1. Write 0x0000 into the cell of interest (erases the cell) + buf[2] = buf[3] = 0; + buf[4] = crc8_pec(buf, 4); + auto ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't erase register 0x%02X, error %d", reg, ec); + return false; + } + + // 2. Wait at least 5ms + delay(10); + + // 3. Write the new value + if (data != 0) { + buf[2] = data & 0xFF; + buf[3] = data >> 8; + buf[4] = crc8_pec(buf, 4); + ec = this->write_register(reg, buf + 2, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't write register 0x%02X, error %d", reg, ec); + return false; + } + // 4. Wait at least 5ms + delay(10); + } + + // 5. Read back to confirm the value was stored + uint16_t read_back; + ec = this->read_register_(reg, read_back); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "Can't check register 0x%02X value, error %d", reg, ec); + return false; + } + + if (read_back != data) { + ESP_LOGW(TAG, "Read back mismatch on register 0x%02X. Expected 0x%04X, got 0x%04X", reg, data, read_back); + return false; + } + + return true; +} + +i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) { + // The PEC covers the whole read transaction: SLA+W, command, SLA+R, data low, data high + uint8_t buf[6]; + buf[0] = this->address_ << 1; + buf[1] = reg; + buf[2] = (this->address_ << 1) | 0x01; + + const auto ec = this->read_register(reg, buf + 3, 3); + if (ec != i2c::ERROR_OK) { + ESP_LOGW(TAG, "i2c read error %d", ec); + return ec; + } + + const auto expected_pec = crc8_pec(buf, 5); + if (buf[5] != expected_pec) { + ESP_LOGW(TAG, "i2c CRC error. Expected 0x%02X, got 0x%02X", expected_pec, buf[5]); + return i2c::ERROR_CRC; + } + + data = encode_uint16(buf[4], buf[3]); + return i2c::ERROR_OK; } void MLX90614Component::dump_config() { ESP_LOGCONFIG(TAG, "MLX90614:"); LOG_I2C_DEVICE(this); - if (this->is_failed()) { - ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + if (this->emissivity_write_attempts_ != 0) { + ESP_LOGW(TAG, " Emissivity not written yet, will retry"); } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Ambient", this->ambient_sensor_); @@ -71,33 +156,41 @@ void MLX90614Component::dump_config() { } void MLX90614Component::update() { - uint8_t emissivity[3]; - if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; + // Temperature reads run regardless of the emissivity state so a failure still shows up as NAN + this->try_write_emissivity_(); + + // Publishes NAN on a bus or CRC failure so a stuck reading is visible instead of silently stale + auto publish_sensor = [this](sensor::Sensor *sensor, uint8_t reg) { + if (sensor == nullptr) { + return i2c::ERROR_OK; + } + + uint16_t raw; + const auto ec = this->read_register_(reg, raw); + if (ec != i2c::ERROR_OK) { + sensor->publish_state(NAN); + return ec; + } + + // Bit 15 set means the device flagged the reading as invalid + const float temperature = (raw & 0x8000) ? NAN : raw * 0.02f - 273.15f; + ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", sensor->get_name().c_str(), temperature); + sensor->publish_state(temperature); + return ec; + }; + + const auto object_ec = publish_sensor(this->object_sensor_, MLX90614_TEMPERATURE_OBJECT_1); + const auto ambient_ec = publish_sensor(this->ambient_sensor_, MLX90614_TEMPERATURE_AMBIENT); + + if (object_ec != i2c::ERROR_OK || ambient_ec != i2c::ERROR_OK) { + this->status_set_warning(LOG_STR("Failed to read some sensors")); + } else if (this->emissivity_write_failed_) { + this->status_set_warning(LOG_STR("Failed to write emissivity")); + } else if (this->emissivity_write_attempts_ != 0) { + this->status_set_warning(LOG_STR("Failed to write emissivity, will retry")); + } else { + this->status_clear_warning(); } - uint8_t raw_object[3]; - if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - uint8_t raw_ambient[3]; - if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) { - this->status_set_warning(); - return; - } - - float ambient = raw_ambient[1] & 0x80 ? NAN : encode_uint16(raw_ambient[1], raw_ambient[0]) * 0.02f - 273.15f; - float object = raw_object[1] & 0x80 ? NAN : encode_uint16(raw_object[1], raw_object[0]) * 0.02f - 273.15f; - - ESP_LOGD(TAG, "Got Temperature=%.1f°C Ambient=%.1f°C", object, ambient); - - if (this->ambient_sensor_ != nullptr && !std::isnan(ambient)) - this->ambient_sensor_->publish_state(ambient); - if (this->object_sensor_ != nullptr && !std::isnan(object)) - this->object_sensor_->publish_state(object); - this->status_clear_warning(); } } // namespace esphome::mlx90614 diff --git a/esphome/components/mlx90614/mlx90614.h b/esphome/components/mlx90614/mlx90614.h index 882ee45186..758792aced 100644 --- a/esphome/components/mlx90614/mlx90614.h +++ b/esphome/components/mlx90614/mlx90614.h @@ -18,13 +18,18 @@ class MLX90614Component final : public PollingComponent, public i2c::I2CDevice { void set_emissivity(float emissivity) { emissivity_ = emissivity; } protected: + void try_write_emissivity_(); bool write_emissivity_(); - bool write_bytes_(uint8_t reg, uint16_t data); + bool write_register_(uint8_t reg, uint16_t data); + i2c::ErrorCode read_register_(uint8_t reg, uint16_t &data); sensor::Sensor *ambient_sensor_{nullptr}; sensor::Sensor *object_sensor_{nullptr}; float emissivity_{NAN}; + // Remaining attempts to program the emissivity EEPROM cell, bounded to limit cell wear + uint8_t emissivity_write_attempts_{0}; + bool emissivity_write_failed_{false}; }; } // namespace esphome::mlx90614 diff --git a/esphome/components/mlx90614/sensor.py b/esphome/components/mlx90614/sensor.py index 0cf9b95dde..e818ea708d 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 a9f240508c..70c70877b7 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 797181690f..48c532b0c6 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 0a34ed037d..cad2781cc1 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -7,14 +7,9 @@ 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_DISABLE_CRC, - CONF_FLOW_CONTROL_PIN, - CONF_ID, -) +from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj from esphome.cpp_helpers import gpio_pin_expression import esphome.final_validate as fv @@ -51,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. @@ -279,16 +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, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), + 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) @@ -297,10 +289,6 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusServer), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - # Remove before 2026.10.0 - cv.Optional(CONF_DISABLE_CRC): cv.invalid( - "'disable_crc' has been removed. The parser no longer requires it — remove this option." - ), } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 037901a873..5f237be8dd 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/modbus/modbus.h b/esphome/components/modbus/modbus.h index 1623c099a3..298cd9f527 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -252,14 +252,6 @@ class ModbusClientHub : public Modbus { void set_turnaround_time(uint16_t time_in_ms) { this->turnaround_delay_us_ = time_in_ms * 1000UL; } bool tx_buffer_empty(); bool tx_blocked() override; - ESPDEPRECATED("Use queue_pdu() with create_client_pdu() instead. Removed in 2026.10.0", "2026.4.0") - void send(uint8_t address, uint8_t function_code, uint16_t start_address, uint16_t number_of_entities, - uint8_t payload_len = 0, const uint8_t *payload = nullptr, ModbusClientDevice *device = nullptr) { - this->queue_pdu(address, - helpers::create_client_pdu((FunctionCode) function_code, start_address, number_of_entities, payload, - payload_len), - device); - }; /// Queue a request. True = accepted: it resolves in exactly one terminal callback (a broadcast, /// address 0, gets only on_sent()). False = refused, and no callback of any kind follows. /// Neither means anything reached the wire - on_sent() reports that. diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 741d4f6f00..d21b319435 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -30,53 +30,10 @@ using modbus::ModbusFunctionCode; using modbus::ModbusRegisterType; #pragma GCC diagnostic pop -// Remove before 2026.10.0 — these helpers have moved to modbus::helpers -ESPDEPRECATED("Use modbus::helpers::value_type_is_float() instead. Removed in 2026.10.0", "2026.4.0") -inline bool value_type_is_float(SensorValueType v) { return modbus::helpers::value_type_is_float(v); } - -ESPDEPRECATED("Use modbus::helpers::modbus_register_read_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_read_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_read_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::modbus_register_write_function() instead. Removed in 2026.10.0", "2026.4.0") -inline FunctionCode modbus_register_write_function(modbus::EntityType reg_type) { - return modbus::helpers::modbus_register_write_function(reg_type); -} - -ESPDEPRECATED("Use modbus::helpers::c_to_hex() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t c_to_hex(char c) { return modbus::helpers::c_to_hex(c); } - -ESPDEPRECATED("Use modbus::helpers::byte_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint8_t byte_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::byte_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::word_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint16_t word_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::word_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::dword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint32_t dword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::dword_from_hex_str(value, pos); -} - -ESPDEPRECATED("Use modbus::helpers::qword_from_hex_str() instead. Removed in 2026.10.0", "2026.4.0") -inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) { - return modbus::helpers::qword_from_hex_str(value, pos); -} - -template -ESPDEPRECATED("Use modbus::helpers::get_data() instead. Removed in 2026.10.0", "2026.4.0") -T get_data(const std::vector &data, size_t buffer_offset) { - return modbus::helpers::get_data(data, buffer_offset); -} - -// Span overloads of the deprecated helpers below: read lambdas receive their payload as a +// Span overloads of the former modbus_controller helpers: read lambdas receive their payload as a // std::span (previously a const std::vector &), and a span does not convert to // a vector, so existing lambdas calling these by name need an overload that accepts one. These carry -// this release's deprecation window, since the span forms only exist from it. +// the 2026.8.0 deprecation window, since the span forms only exist from it. // payload_to_number() deliberately has no such overload: one of its arguments is a modbus::helpers // type, so a span call already reaches the helper by argument-dependent lookup, and a forwarder here // would only make that call ambiguous. @@ -99,33 +56,6 @@ inline bool coil_from_vector(int coil, std::span data) { return modbus::helpers::bit_from_packed(coil, data); } -template -ESPDEPRECATED("Use modbus::helpers::mask_and_shift_by_rightbit() instead. Removed in 2026.10.0", "2026.4.0") -N mask_and_shift_by_rightbit(N data, uint32_t mask) { - return modbus::helpers::mask_and_shift_by_rightbit(data, mask); -} - -ESPDEPRECATED("Use modbus::helpers::number_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline void number_to_payload(std::vector &data, int64_t value, SensorValueType value_type) { - modbus::helpers::number_to_payload(data, value, value_type); -} - -ESPDEPRECATED("Use modbus::helpers::payload_to_number() instead. Removed in 2026.10.0", "2026.4.0") -inline int64_t payload_to_number(const std::vector &data, SensorValueType sensor_value_type, uint8_t offset, - uint32_t bitmask) { - return modbus::helpers::payload_to_number(std::span(data), sensor_value_type, offset, bitmask) - .value_or(0); -} - -ESPDEPRECATED("Use modbus::helpers::float_to_payload() instead. Removed in 2026.10.0", "2026.4.0") -inline std::vector float_to_payload(float value, SensorValueType value_type) { - std::vector data; - modbus::helpers::float_to_payload(data, value, value_type); - return data; -} - -class ModbusController; - /// How an item relates to the register range built just before it (same register type, address order). /// The numeric order doubles as the comparator tiebreak for items at the same address (see /// SensorItemsComparator): AUTO items form the shared range first, so a NEVER item comes last and diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index aff05cd517..223aa12bec 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -23,7 +23,6 @@ void ModbusNumber::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Number new state : %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index b2bc2b5fd0..2035f2220a 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -22,7 +22,6 @@ void ModbusSensor::parse_and_publish(std::span data) { } } ESP_LOGD(TAG, "Sensor new state: %.02f", result); - // this->sensor_->raw_state = result; this->publish_state(result); } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 7bf45366c0..855a7b28c3 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -16,17 +16,11 @@ void ModbusSwitch::setup() { optional initial_state = Switch::get_initial_state_with_restore_mode(); if (initial_state.has_value()) { // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); } -void ModbusSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } - bool ModbusSwitch::assumed_state() { return this->assumed_state_; } void ModbusSwitch::parse_and_publish(std::span data) { diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index 688a620bac..b98543532e 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -31,7 +31,7 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens void setup() override; void write_state(bool state) override; void dump_config() override; - void set_assumed_state(bool assumed_state); + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void set_state(bool state) { this->state = state; } void parse_and_publish(std::span data) override; void set_parent(ModbusController *parent) { this->set_controller_(parent); } diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 5f7c9445f2..761e5831c1 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 d5c5d4135d..b3e55766d8 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 aea052fa2f..741996cbb8 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 0000000000..0a3876c8ab --- /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 0000000000..f663b41adb --- /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 0000000000..6d556598d4 --- /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 0000000000..9c72535f13 --- /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 0000000000..5568c953fd --- /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 0000000000..e8cd9f5352 --- /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 8715c8385c..226dc94e63 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 b0a074a17c..2ca920dbe0 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 ad3163a01a..db8a869236 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 4fbc353644..8235e2d618 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 a8370ced7f..bd3de2d32c 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 5bdb836128..878540d516 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 3050ceb1a4..65a7cf409b 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 09eb5f97dc..791e550496 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 fe0966e725..ced9c84e10 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 a80cea6bd6..59a5d02d97 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 7920187f92..369c1c6668 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 1c0625d1c9..a601a9bcc8 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -1,6 +1,7 @@ #include #include "mqtt_sensor.h" #include "esphome/core/log.h" +#include "esphome/core/progmem.h" #include "mqtt_const.h" @@ -59,11 +60,7 @@ void MQTTSensorComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCon root[MQTT_FORCE_UPDATE] = true; if (this->sensor_->get_state_class() != STATE_CLASS_NONE) { -#ifdef USE_STORE_LOG_STR_IN_FLASH - root[MQTT_STATE_CLASS] = (const __FlashStringHelper *) state_class_to_string(this->sensor_->get_state_class()); -#else - root[MQTT_STATE_CLASS] = LOG_STR_ARG(state_class_to_string(this->sensor_->get_state_class())); -#endif + root[MQTT_STATE_CLASS] = reinterpret_cast(state_class_to_string(this->sensor_->get_state_class())); } config.command_topic = false; diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp index 40b5b46e1d..afb725feb6 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp @@ -25,7 +25,6 @@ void MQTTSubscribeSensor::setup() { } float MQTTSubscribeSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeSensor::dump_config() { LOG_SENSOR("", "MQTT Subscribe", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h index 739e8456ee..b0a8a0a78a 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.h @@ -18,7 +18,7 @@ class MQTTSubscribeSensor final : public sensor::Sensor, public Component { void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp index edc197671e..470e08d59a 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.cpp @@ -15,7 +15,6 @@ void MQTTSubscribeTextSensor::setup() { this->qos_); } float MQTTSubscribeTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } -void MQTTSubscribeTextSensor::set_qos(uint8_t qos) { this->qos_ = qos; } void MQTTSubscribeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "MQTT Subscribe Text Sensor", this); ESP_LOGCONFIG(TAG, " Topic: %s", this->topic_.c_str()); diff --git a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h index 8641825fca..dc02eb5d18 100644 --- a/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h +++ b/esphome/components/mqtt_subscribe/text_sensor/mqtt_subscribe_text_sensor.h @@ -17,7 +17,7 @@ class MQTTSubscribeTextSensor final : public text_sensor::TextSensor, public Com void setup() override; void dump_config() override; float get_setup_priority() const override; - void set_qos(uint8_t qos); + void set_qos(uint8_t qos) { this->qos_ = qos; } protected: mqtt::MQTTClientComponent *parent_; diff --git a/esphome/components/ms5611/sensor.py b/esphome/components/ms5611/sensor.py index e42e0824dd..bf6d4528e6 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 e5d90432d8..13377394be 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 c53a018234..e31070e0a3 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 415ae09daf..2647d40441 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 9a47679ba3..decee27bcf 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 28f83cc4fa..3b0621d1f3 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 0226c65be6..0000000000 --- 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 29f5bdaea7..6c390e1c6e 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 3f5ba94b40..18f0a73928 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -14,12 +14,7 @@ from esphome.const import ( ) from esphome.core import CORE, TimePeriod -from . import ( # noqa: F401 pylint: disable=unused-import - FILTER_SOURCE_FILES, - Nextion, - nextion_ns, - nextion_ref, -) +from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from .base_component import ( CONF_AUTO_WAKE_ON_TOUCH, CONF_COMMAND_SPACING, @@ -61,11 +56,6 @@ def AUTO_LOAD() -> list[str]: return base -NextionSetBrightnessAction = nextion_ns.class_( - "NextionSetBrightnessAction", automation.Action -) - - def _deprecated_dump_device_info(value): _LOGGER.warning( "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " @@ -165,9 +155,8 @@ CONFIG_SCHEMA = cv.All( ) -@automation.register_action( +automation.register_apply_action( "display.nextion.set_brightness", - NextionSetBrightnessAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(Nextion), @@ -175,16 +164,9 @@ CONFIG_SCHEMA = cv.All( }, key=CONF_BRIGHTNESS, ), - synchronous=True, + automation.ApplyField(CONF_BRIGHTNESS, "set_brightness", cg.float_), + automation.ApplyField(CONF_BRIGHTNESS, "set_backlight_brightness", cg.float_), ) -async def nextion_set_brightness_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - - template_ = await cg.templatable(config[CONF_BRIGHTNESS], args, cg.float_) - cg.add(var.set_brightness(template_)) - - return var _CALLBACK_AUTOMATIONS = ( @@ -289,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 6c9c8760f8..320aec048a 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 a356d54e2f..dd3a97065c 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -217,7 +217,6 @@ void Nextion::set_component_value(const char *component, int32_t value) { this->add_no_result_to_queue_with_printf_(".val", "%s.val=%" PRId32, component, value); } -#ifdef USE_NEXTION_WAVEFORM void Nextion::add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value) { this->add_no_result_to_queue_with_printf_("add", "add %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); @@ -227,7 +226,6 @@ void Nextion::open_waveform_channel(uint8_t component_id, uint8_t channel_number this->add_no_result_to_queue_with_printf_("addt", "addt %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); } -#endif // USE_NEXTION_WAVEFORM void Nextion::set_component_coordinates(const char *component, uint16_t x, uint16_t y) { this->add_no_result_to_queue_with_printf_(".xcen", "%s.xcen=%" PRIu16, component, x); diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 5e84291b16..b66c0b9e4e 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -66,6 +66,7 @@ class NextionComponentBase { #ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } const std::vector &get_wave_buffer() const { return this->wave_buffer_; } size_t get_wave_buffer_size() const { return this->wave_buffer_.size(); } @@ -86,12 +87,6 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } -#ifdef USE_NEXTION_WAVEFORM - // Remove before 2026.10.0 - ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") - uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } - void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } -#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index 61cb42e62c..6c93286656 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 29749ecab0..5053c30eb3 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 168a672497..b8fd0c8fda 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 eea1a47b24..6afc2eca09 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 ec3a979b64..6e03430d6f 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 e9747d7a14..2fde6804da 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 6e8162fc91..995255d1e4 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 6354e16967..f5def15834 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 0b60fd0ade..d00c8af5ea 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 8e8b110336..de8a4cf56a 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 d33f3f7b5c..e597b9192d 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 7d431b2296..25ec7c00c7 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 76a391f1de..a9fc1c2b8c 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 36b27ce5f6..e0f73e934c 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 6c8a5b626d..82f4591bdf 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 dedc602bf1..eff6db0ea2 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 6cc1a00c62..a20c435e21 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 47cd4cfc67..d9880fe6f9 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -5,12 +5,16 @@ 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 CORE, ID +from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] -DOMAIN = "noise" + +# Keep in sync with platformio.ini and esphome/idf_component.yml. +# LIBSODIUM_VERSION must match the version noise-c pins in its manifests. +NOISE_C_VERSION = "0.1.30" +LIBSODIUM_VERSION = "1.10021.11" noise_ns = cg.esphome_ns.namespace("noise") @@ -69,18 +73,13 @@ 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. Components sharing one key (api and ota) share the array.""" - decoded = decode_encryption_key(key) - arrays: dict[bytes, MockObj] = CORE.data.setdefault(DOMAIN, {}) - if (array := arrays.get(decoded)) is None: - array = arrays[decoded] = cg.progmem_array( - ID(f"{parent_id.id}_psk", is_declaration=True, type=cg.uint8), - list(decoded), - ) - return array + return cg.shared_progmem_array( + "noise_psk", cg.uint8, list(decode_encryption_key(key)) + ) def encryption_schema(config: ConfigType | None) -> ConfigType: @@ -92,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 4806706167..118bfb234f 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 1033d5423c..271a829944 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 cc7fa603c4..bd1afa198d 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 bf1aa8cb7f..ef556a26b0 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 4c7db10ce9..14dcd72384 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 aeeaba0c11..86d485777c 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 2dd4b7bd09..d1f9597976 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 0122ed9b31..3d9219e96f 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,7 +25,13 @@ 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) { diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 5e2cf197fb..00796562f4 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 0000000000..aaa317aa5f --- /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 fc0893323b..8edbdecee5 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 4efcfd30d8..b3d9dfaffb 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -6,47 +6,6 @@ namespace esphome::number { -class NumberStateTrigger final : public Trigger { - public: - explicit NumberStateTrigger(Number *parent) { - parent->add_on_state_callback([this](float value) { this->trigger(value); }); - } -}; - -template class NumberSetAction final : public Action { - public: - NumberSetAction(Number *number) : number_(number) {} - TEMPLATABLE_VALUE(float, value) - - void play(const Ts &...x) override { - auto call = this->number_->make_call(); - call.set_value(this->value_.value(x...)); - call.perform(); - } - - protected: - Number *number_; -}; - -template class NumberOperationAction final : public Action { - public: - explicit NumberOperationAction(Number *number) : number_(number) {} - TEMPLATABLE_VALUE(NumberOperation, operation) - TEMPLATABLE_VALUE(bool, cycle) - - void play(const Ts &...x) override { - auto call = this->number_->make_call(); - call.with_operation(this->operation_.value(x...)); - if (this->cycle_.has_value()) { - call.with_cycle(this->cycle_.value(x...)); - } - call.perform(); - } - - protected: - Number *number_; -}; - class ValueRangeTrigger final : public Trigger, public Component { public: explicit ValueRangeTrigger(Number *parent) : parent_(parent) {} diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index 579d488cf0..b697e770be 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -28,7 +28,7 @@ class Number; */ class Number : public EntityBase { public: - float state; + float state{}; void publish_state(float state); diff --git a/esphome/components/online_image/image.py b/esphome/components/online_image/image.py index 3e9517937e..a36f3a9494 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 3f2382accd..a21849d618 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 3e386f8cc8..8b3485b8f7 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 ab69f5d9ae..80b28a2628 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,10 +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 @@ -28,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 @@ -109,6 +113,14 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) + # 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") + ) + if not config.get(CONF_TLV): if pan_id := config.get(CONF_PAN_ID): add_idf_sdkconfig_option("CONFIG_OPENTHREAD_NETWORK_PANID", pan_id) @@ -282,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): @@ -309,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 @@ -318,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( @@ -337,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 770bf124c5..0000000000 --- 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 3706499fda..0000000000 --- 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 b98f109172..7d98522cd2 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -227,28 +227,30 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { return ptr; } -void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } - bool OpenThreadComponent::teardown() { switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { - 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; @@ -256,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"); @@ -284,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 f4c6d0962a..8599304c89 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: @@ -90,7 +85,7 @@ extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguide class OpenThreadSrpComponent final : public Component { public: - void set_mdns(esphome::mdns::MDNSComponent *mdns); + void set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } // This has to run after the mdns component or else no services are available to advertise float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0f; } void setup() override; diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 881bbea3c9..526fc961f2 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 cacb4c0122..0f474fc040 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 da789ae706..4d3e8cd6c3 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 a2e6953a16..6031a68489 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 7348a0ce90..a505d58efe 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 c2716a44d1..c322ed21f2 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 f7c0037bd2..15142869ac 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 21b5c12c2d..1f1ec37eee 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 c991f896e8..4f4093a594 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 82d001ed9e..06c58582b5 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 51ffdaeda3..e53868f102 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 10d5e5eb59..1b79403784 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 efe775ba57..0000000000 --- a/esphome/components/output/automation.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/core/defines.h" -#include "esphome/components/output/binary_output.h" -#include "esphome/components/output/float_output.h" - -namespace esphome::output { - -template class TurnOffAction final : public Action { - public: - TurnOffAction(BinaryOutput *output) : output_(output) {} - - void play(const Ts &...x) override { this->output_->turn_off(); } - - protected: - BinaryOutput *output_; -}; - -template class TurnOnAction final : public Action { - public: - TurnOnAction(BinaryOutput *output) : output_(output) {} - - void play(const Ts &...x) override { this->output_->turn_on(); } - - protected: - BinaryOutput *output_; -}; - -template class SetLevelAction final : public Action { - public: - SetLevelAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, level) - - void play(const Ts &...x) override { this->output_->set_level(this->level_.value(x...)); } - - protected: - FloatOutput *output_; -}; - -#ifdef USE_OUTPUT_FLOAT_POWER_SCALING -template class SetMinPowerAction final : public Action { - public: - SetMinPowerAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, min_power) - - void play(const Ts &...x) override { this->output_->set_min_power(this->min_power_.value(x...)); } - - protected: - FloatOutput *output_; -}; - -template class SetMaxPowerAction final : public Action { - public: - SetMaxPowerAction(FloatOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, max_power) - - void play(const Ts &...x) override { this->output_->set_max_power(this->max_power_.value(x...)); } - - protected: - FloatOutput *output_; -}; -#endif // USE_OUTPUT_FLOAT_POWER_SCALING - -} // namespace esphome::output diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index 7cee2a8639..a21cbc0f6b 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -6,21 +6,9 @@ namespace esphome::output { static const char *const TAG = "output.switch"; void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); } -void OutputSwitch::setup() { - bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - - if (initial_state) { - this->turn_on(); - } else { - this->turn_off(); - } -} +void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); } void OutputSwitch::write_state(bool state) { - if (state) { - this->output_->turn_on(); - } else { - this->output_->turn_off(); - } + this->output_->set_state(state); this->publish_state(state); } diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 171c6753ae..703af8281d 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -128,7 +128,7 @@ REMOTE_PACKAGE_SCHEMA = cv.All( cv.Required(CONF_URL): cv.url, cv.Optional(CONF_PATH): cv.string, cv.Optional(CONF_USERNAME): cv.string, - cv.Optional(CONF_PASSWORD): cv.string, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.string), cv.Exclusive(CONF_FILE, CONF_FILES): validate_yaml_filename, cv.Exclusive(CONF_FILES, CONF_FILES): cv.All( cv.ensure_list( diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 47b9118d9a..9756e9431a 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 5272df2b55..68e6d163e8 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/pca9554/pca9554.h b/esphome/components/pca9554/pca9554.h index 05e945d176..cc95f147ac 100644 --- a/esphome/components/pca9554/pca9554.h +++ b/esphome/components/pca9554/pca9554.h @@ -55,6 +55,9 @@ class PCA9554Component final : public Component, /// Helper class to expose a PCA9554 pin as an internal input GPIO pin. class PCA9554GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCA9554GPIOPin()` would zero-fill .bss that is already zero. + PCA9554GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -69,10 +72,10 @@ class PCA9554GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCA9554Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCA9554Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pca9554 diff --git a/esphome/components/pcf85063/pcf85063.h b/esphome/components/pcf85063/pcf85063.h index 659260ba5e..717803490a 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 771461905e..3f993c17e1 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 e208774c2c..2921d4f047 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 8a0b871be9..3f677280c5 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 9f8c7fabd2..4ee1c2af56 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/pcf8574/pcf8574.h b/esphome/components/pcf8574/pcf8574.h index e8f78bae50..9879d6a47e 100644 --- a/esphome/components/pcf8574/pcf8574.h +++ b/esphome/components/pcf8574/pcf8574.h @@ -51,6 +51,9 @@ class PCF8574Component final : public Component, /// Helper class to expose a PCF8574 pin as an internal input GPIO pin. class PCF8574GPIOPin final : public GPIOPin { public: + // User provided, not "= default": `new(p) PCF8574GPIOPin()` would zero-fill .bss that is already zero. + PCF8574GPIOPin() {} + void setup() override; void pin_mode(gpio::Flags flags) override; bool digital_read() override; @@ -65,10 +68,10 @@ class PCF8574GPIOPin final : public GPIOPin { gpio::Flags get_flags() const override { return this->flags_; } protected: - PCF8574Component *parent_; - uint8_t pin_; - bool inverted_; - gpio::Flags flags_; + PCF8574Component *parent_{nullptr}; + uint8_t pin_{0}; + bool inverted_{false}; + gpio::Flags flags_{}; }; } // namespace esphome::pcf8574 diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 11a5dd62bb..45d84b88ef 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 4945547f2e..1527fa6f35 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 8c9231fda6..1f0d45b084 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 7269709ab9..6fea2bedf3 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 94d641de47..242a2440f8 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 4e963168e6..afc77efde3 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 b62d597780..1d208c44f8 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 62e6d0f113..e3c841d1dd 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 6fc013c276..bf82fa63a8 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.h +++ b/esphome/components/pipsolar/output/pipsolar_output.h @@ -27,16 +27,4 @@ class PipsolarOutput final : public output::FloatOutput { std::vector possible_values_; }; -template class SetOutputAction final : public Action { - public: - SetOutputAction(PipsolarOutput *output) : output_(output) {} - - TEMPLATABLE_VALUE(float, level) - - void play(const Ts &...x) override { this->output_->set_value(this->level_.value(x...)); } - - protected: - PipsolarOutput *output_; -}; - } // namespace esphome::pipsolar diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index 4436b1fb17..aa035be347 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -120,7 +120,7 @@ void Pixoo::set_pixel_(uint32_t index, Color color) { } void HOT Pixoo::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; const int side = static_cast(this->model_); switch (this->rotation_) { diff --git a/esphome/components/pm1006/sensor.py b/esphome/components/pm1006/sensor.py index 447671ebb3..98c6406676 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 f16c100e5e..a0c133eba1 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 0b5c72a94d..f20b93fff8 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 908b073be1..6eccde3665 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 dc85380203..5eac5415ed 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 4ce4a5ce9c..8ca995fbe7 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 ae22b3e0d6..61f89afa41 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 6258932312..c9118834c5 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 8f490ba7d0..b9cf9a1c27 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 8ef7721726..89392105a2 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 629a697aa5..5502eac6ec 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 37674080d8..9f937b3e28 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 eb3d13a7e0..90c8523722 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 7f4d78461b..7f00e18d4c 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 6495f17599..f148e196a0 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 13d9aebc20..73d65e4330 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 f29950c423..b051cd781e 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 4638992abf..14299ee310 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 c3f8d3e5d3..0000000000 --- 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 4e679c664a..25bb7a0c24 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 fa38c5c313..7740954050 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 ef594144d9..0000000000 --- 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 a61bd27c64..58d9daa311 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 7f9f9172a1..4cde36e9e7 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 9f03a5a3d6..0000000000 --- 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 f2cbfa6bcf..185ff0ce09 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 da4577874c..b44a595cce 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 0dc8bbdfe4..0000000000 --- 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 c34cf90e68..d559c6c251 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 f3c413e952..85a6c1fb7d 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 0000000000..8472d4192c --- /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 0000000000..6a24acd843 --- /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 0000000000..940d1874fc --- /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 f1832d95f1..c1c927634d 100644 --- a/esphome/components/pn7150/pn7150_mifare_classic.cpp +++ b/esphome/components/pn71xx/pn71xx_mifare_classic.cpp @@ -1,14 +1,16 @@ +#include #include +#include #include -#include "pn7150.h" +#include "pn71xx.h" #include "esphome/core/log.h" -namespace esphome::pn7150 { +namespace esphome::pn71xx { -static const char *const TAG = "pn7150.mifare_classic"; +static const char *const TAG = "pn71xx.mifare_classic"; -uint8_t PN7150::read_mifare_classic_tag_(nfc::NfcTag &tag) { +uint8_t PN71xx::read_mifare_classic_tag_(nfc::NfcTag &tag) { uint8_t current_block = 4; uint8_t message_start_index = 0; uint32_t message_length = 0; @@ -17,60 +19,63 @@ uint8_t PN7150::read_mifare_classic_tag_(nfc::NfcTag &tag) { ESP_LOGE(TAG, "Tag auth failed while attempting to read tag data"); return nfc::STATUS_FAILED; } - std::vector data; + std::array block_data; - if (this->read_mifare_classic_block_(current_block, data) == nfc::STATUS_OK) { - if (!nfc::decode_mifare_classic_tlv(data, message_length, message_start_index)) { + if (this->read_mifare_classic_block_(current_block, block_data) == nfc::STATUS_OK) { + if (!nfc::decode_mifare_classic_tlv(block_data, message_length, message_start_index)) { return nfc::STATUS_FAILED; } } else { ESP_LOGE(TAG, "Failed to read block %u", current_block); return nfc::STATUS_FAILED; } + if (message_length > MIFARE_CLASSIC_MAX_NDEF_SIZE) { + ESP_LOGE(TAG, "NDEF message too long: %" PRIu32 " bytes", message_length); + return nfc::STATUS_FAILED; + } - uint32_t index = 0; - uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); - std::vector buffer; + const uint32_t buffer_size = nfc::get_mifare_classic_buffer_size(message_length); + FixedVector buffer; + if (!buffer.try_init(buffer_size)) { + ESP_LOGE(TAG, "Out of memory reading NDEF message of %" PRIu32 " bytes", buffer_size); + return nfc::STATUS_FAILED; + } - while (index < buffer_size) { + while (buffer.size() < buffer_size) { if (nfc::mifare_classic_is_first_block(current_block)) { if (this->auth_mifare_classic_block_(current_block, nfc::MIFARE_CMD_AUTH_A, nfc::NDEF_KEY) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Block authentication failed for %u", current_block); return nfc::STATUS_FAILED; } } - std::vector block_data; if (this->read_mifare_classic_block_(current_block, block_data) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error reading block %u", current_block); return nfc::STATUS_FAILED; - } else { - buffer.insert(buffer.end(), block_data.begin(), block_data.end()); + } + for (const uint8_t byte : block_data) { + buffer.push_back(byte); } - index += nfc::MIFARE_CLASSIC_BLOCK_SIZE; current_block++; - if (nfc::mifare_classic_is_trailer_block(current_block)) { current_block++; } } - if (buffer.begin() + message_start_index < buffer.end()) { - buffer.erase(buffer.begin(), buffer.begin() + message_start_index); - } else { + if (message_start_index >= buffer.size()) { return nfc::STATUS_FAILED; } - - tag.set_ndef_message(make_unique(buffer)); + tag.set_ndef_message(make_unique(std::span(buffer).subspan(message_start_index))); return nfc::STATUS_OK; } -uint8_t PN7150::read_mifare_classic_block_(uint8_t block_num, std::vector &data) { +uint8_t PN71xx::read_mifare_classic_block_(uint8_t block_num, + std::array &data) { nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_READ, block_num}); - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGVV(TAG, "Read XCHG_DATA_REQ: %s", nfc::format_bytes_to(buf, tx.get_message())); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Timeout reading tag data"); @@ -84,32 +89,35 @@ uint8_t PN7150::read_mifare_classic_block_(uint8_t block_num, std::vectorsect_to_auth_(block_num), key_num}); - +uint8_t PN71xx::auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, const uint8_t *key) { + uint8_t key_select = key_num; switch (key_num) { case nfc::MIFARE_CMD_AUTH_A: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_A; + key_select = MFC_AUTHENTICATE_PARAM_KS_A; break; case nfc::MIFARE_CMD_AUTH_B: - tx.get_message().back() = MFC_AUTHENTICATE_PARAM_KS_B; + key_select = MFC_AUTHENTICATE_PARAM_KS_B; break; default: break; } - if (key != nullptr) { - tx.get_message().back() |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; - tx.get_message().insert(tx.get_message().end(), key, key + 6); + key_select |= MFC_AUTHENTICATE_PARAM_EMBED_KEY; + } + + nfc::NciMessage rx; + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {MFC_AUTHENTICATE_OID, this->sect_to_auth_(block_num), key_select}); + if (key != nullptr) { + tx.append(std::span(key, 6)); } char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; @@ -129,7 +137,7 @@ uint8_t PN7150::auth_mifare_classic_block_(uint8_t block_num, uint8_t key_num, c return nfc::STATUS_OK; } -uint8_t PN7150::sect_to_auth_(const uint8_t block_num) { +uint8_t PN71xx::sect_to_auth_(const uint8_t block_num) { const uint8_t first_high_block = nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW * nfc::MIFARE_CLASSIC_16BLOCK_SECT_START; if (block_num >= first_high_block) { return ((block_num - first_high_block) / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_HIGH) + @@ -138,7 +146,7 @@ uint8_t PN7150::sect_to_auth_(const uint8_t block_num) { return block_num / nfc::MIFARE_CLASSIC_BLOCKS_PER_SECT_LOW; } -uint8_t PN7150::format_mifare_classic_mifare_() { +uint8_t PN71xx::format_mifare_classic_mifare_() { static constexpr std::array BLANK_BUFFER = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; static constexpr std::array TRAILER_BUFFER = { @@ -173,7 +181,7 @@ uint8_t PN7150::format_mifare_classic_mifare_() { return status; } -uint8_t PN7150::format_mifare_classic_ndef_() { +uint8_t PN71xx::format_mifare_classic_ndef_() { static constexpr std::array EMPTY_NDEF_MESSAGE = { 0x03, 0x03, 0xD0, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; static constexpr std::array BLANK_BLOCK = { @@ -237,11 +245,11 @@ uint8_t PN7150::format_mifare_classic_ndef_() { return status; } -uint8_t PN7150::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { +uint8_t PN71xx::write_mifare_classic_block_(uint8_t block_num, const uint8_t *data, size_t len) { nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {XCHG_DATA_OID, nfc::MIFARE_CMD_WRITE, block_num}); - char buf[nfc::FORMAT_BYTES_BUFFER_SIZE]; + ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 1: %s", nfc::format_bytes_to(buf, tx.get_message())); if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Sending XCHG_DATA_REQ failed"); @@ -249,7 +257,7 @@ uint8_t PN7150::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da } // write command part two tx.set_payload({XCHG_DATA_OID}); - tx.get_message().insert(tx.get_message().end(), data, data + len); + tx.append(std::span(data, len)); ESP_LOGVV(TAG, "Write XCHG_DATA_REQ 2: %s", nfc::format_bytes_to(buf, tx.get_message())); if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { @@ -267,23 +275,11 @@ uint8_t PN7150::write_mifare_classic_block_(uint8_t block_num, const uint8_t *da return nfc::STATUS_OK; } -uint8_t PN7150::write_mifare_classic_tag_(const std::shared_ptr &message) { - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(message_length); - - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); +uint8_t PN71xx::write_mifare_classic_tag_(const std::shared_ptr &message) { + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_classic_buffer_size(encoded.size()); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_block = 4; @@ -295,7 +291,7 @@ uint8_t PN7150::write_mifare_classic_tag_(const std::shared_ptrwrite_mifare_classic_block_(current_block, encoded.data() + index, nfc::MIFARE_CLASSIC_BLOCK_SIZE) != + if (this->write_mifare_classic_block_(current_block, &buffer[index], nfc::MIFARE_CLASSIC_BLOCK_SIZE) != nfc::STATUS_OK) { return nfc::STATUS_FAILED; } @@ -310,7 +306,7 @@ uint8_t PN7150::write_mifare_classic_tag_(const std::shared_ptr #include #include #include -#include "pn7160.h" +#include "pn71xx.h" #include "esphome/core/log.h" -namespace esphome::pn7160 { +namespace esphome::pn71xx { -static const char *const TAG = "pn7160.mifare_ultralight"; +static const char *const TAG = "pn71xx.mifare_ultralight"; -uint8_t PN7160::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { - std::vector data; +uint8_t PN71xx::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { + UltralightReadBuffer data; // pages 3 to 6 contain various info we are interested in -- do one read to grab it all if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE * nfc::MIFARE_ULTRALIGHT_READ_SIZE, data) != nfc::STATUS_OK) { @@ -42,33 +43,41 @@ uint8_t PN7160::read_mifare_ultralight_tag_(nfc::NfcTag &tag) { return nfc::STATUS_FAILED; } } - // we need to trim off page 3 as well as any bytes ahead of message_start_index - data.erase(data.begin(), data.begin() + message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE); - - tag.set_ndef_message(make_unique(data)); + // skip page 3 as well as any bytes ahead of message_start_index + const size_t skip = message_start_index + nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; + if (skip >= data.size()) { + return nfc::STATUS_FAILED; + } + tag.set_ndef_message(make_unique(std::span(data).subspan(skip))); return nfc::STATUS_OK; } -uint8_t PN7160::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, std::vector &data) { +uint8_t PN71xx::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_bytes, UltralightReadBuffer &data) { const uint8_t read_increment = nfc::MIFARE_ULTRALIGHT_READ_SIZE * nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; nfc::NciMessage rx; nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_READ, start_page}); for (size_t i = 0; i * read_increment < num_bytes; i++) { - tx.get_message().back() = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; - do { // loop because sometimes we struggle here...???... + const uint8_t page = i * nfc::MIFARE_ULTRALIGHT_READ_SIZE + start_page; + tx.set_payload({nfc::MIFARE_CMD_READ, page}); + // a short answer (e.g. a NAK for a page beyond the end of the tag) is retried a limited number of times + uint8_t attempts = 0; + do { if (this->transceive_(tx, rx) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error reading tag data"); return nfc::STATUS_FAILED; } + if (++attempts > NFCC_MAX_COMM_FAILS && rx.get_payload_size() < read_increment) { + ESP_LOGE(TAG, "Short read from page %u", page); + return nfc::STATUS_FAILED; + } } while (rx.get_payload_size() < read_increment); - uint16_t bytes_offset = (i + 1) * read_increment; - auto pages_in_end_itr = bytes_offset <= num_bytes ? rx.get_message().end() - 1 - : rx.get_message().end() - (bytes_offset - num_bytes + 1); - - if ((pages_in_end_itr > rx.get_message().begin()) && (pages_in_end_itr < rx.get_message().end())) { - data.insert(data.end(), rx.get_message().begin() + nfc::NCI_PKT_HEADER_SIZE, pages_in_end_itr); + // the payload ends with a status byte; keep only the bytes still wanted from this read + const uint16_t wanted = num_bytes - i * read_increment; + const size_t count = std::min(read_increment, wanted); + for (const uint8_t byte : rx.get_payload().subspan(0, count)) { + data.push_back(byte); } } @@ -78,7 +87,7 @@ uint8_t PN7160::read_mifare_ultralight_bytes_(uint8_t start_page, uint16_t num_b return nfc::STATUS_OK; } -bool PN7160::is_mifare_ultralight_formatted_(const std::vector &page_3_to_6) { +bool PN71xx::is_mifare_ultralight_formatted_(const std::span page_3_to_6) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector return (page_3_to_6.size() > p4_offset + 3) && @@ -86,8 +95,8 @@ bool PN7160::is_mifare_ultralight_formatted_(const std::vector &page_3_ (page_3_to_6[p4_offset + 2] != 0xFF) || (page_3_to_6[p4_offset + 3] != 0xFF)); } -uint16_t PN7160::read_mifare_ultralight_capacity_() { - std::vector data; +uint16_t PN71xx::read_mifare_ultralight_capacity_() { + UltralightReadBuffer data; if (this->read_mifare_ultralight_bytes_(3, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE, data) == nfc::STATUS_OK) { ESP_LOGV(TAG, "Tag capacity is %u bytes", data[2] * 8U); return data[2] * 8U; @@ -95,7 +104,7 @@ uint16_t PN7160::read_mifare_ultralight_capacity_() { return 0; } -uint8_t PN7160::find_mifare_ultralight_ndef_(const std::vector &page_3_to_6, uint8_t &message_length, +uint8_t PN71xx::find_mifare_ultralight_ndef_(const std::span page_3_to_6, uint8_t &message_length, uint8_t &message_start_index) { const uint8_t p4_offset = nfc::MIFARE_ULTRALIGHT_PAGE_SIZE; // page 4 will begin 4 bytes into the vector @@ -115,36 +124,25 @@ uint8_t PN7160::find_mifare_ultralight_ndef_(const std::vector &page_3_ return nfc::STATUS_FAILED; } -uint8_t PN7160::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { +uint8_t PN71xx::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::shared_ptr &message) { uint32_t capacity = this->read_mifare_ultralight_capacity_(); - auto encoded = message->encode(); - - uint32_t message_length = encoded.size(); - uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(message_length); + const auto encoded = message->encode(); + const uint32_t buffer_length = nfc::get_mifare_ultralight_buffer_size(encoded.size()); if (buffer_length > capacity) { ESP_LOGE(TAG, "Message length exceeds tag capacity %" PRIu32 " > %" PRIu32, buffer_length, capacity); return nfc::STATUS_FAILED; } - encoded.insert(encoded.begin(), 0x03); - if (message_length < 255) { - encoded.insert(encoded.begin() + 1, message_length); - } else { - encoded.insert(encoded.begin() + 1, 0xFF); - encoded.insert(encoded.begin() + 2, (message_length >> 8) & 0xFF); - encoded.insert(encoded.begin() + 3, message_length & 0xFF); - } - encoded.push_back(0xFE); - - encoded.resize(buffer_length, 0); + FixedVector buffer; + nfc::fill_ndef_tlv(encoded, buffer_length, buffer); uint32_t index = 0; uint8_t current_page = nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; while (index < buffer_length) { - if (this->write_mifare_ultralight_page_(current_page, encoded.data() + index, nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != + if (this->write_mifare_ultralight_page_(current_page, &buffer[index], nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) != nfc::STATUS_OK) { return nfc::STATUS_FAILED; } @@ -154,7 +152,7 @@ uint8_t PN7160::write_mifare_ultralight_tag_(nfc::NfcTagUid &uid, const std::sha return nfc::STATUS_OK; } -uint8_t PN7160::clean_mifare_ultralight_() { +uint8_t PN71xx::clean_mifare_ultralight_() { uint32_t capacity = this->read_mifare_ultralight_capacity_(); uint8_t pages = (capacity / nfc::MIFARE_ULTRALIGHT_PAGE_SIZE) + nfc::MIFARE_ULTRALIGHT_DATA_START_PAGE; @@ -168,12 +166,10 @@ uint8_t PN7160::clean_mifare_ultralight_() { return nfc::STATUS_OK; } -uint8_t PN7160::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { - std::vector payload = {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}; - payload.insert(payload.end(), write_data, write_data + len); - +uint8_t PN71xx::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *write_data, size_t len) { nfc::NciMessage rx; - nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, payload); + nfc::NciMessage tx(nfc::NCI_PKT_MT_DATA, {nfc::MIFARE_CMD_WRITE_ULTRALIGHT, page_num}); + tx.append(std::span(write_data, len)); if (this->transceive_(tx, rx, NFCC_TAG_WRITE_TIMEOUT) != nfc::STATUS_OK) { ESP_LOGE(TAG, "Error writing page %u", page_num); @@ -182,4 +178,4 @@ uint8_t PN7160::write_mifare_ultralight_page_(uint8_t page_num, const uint8_t *w return nfc::STATUS_OK; } -} // namespace esphome::pn7160 +} // namespace esphome::pn71xx diff --git a/esphome/components/preferences/syncer.h b/esphome/components/preferences/syncer.h index 8a809672db..5092c32147 100644 --- a/esphome/components/preferences/syncer.h +++ b/esphome/components/preferences/syncer.h @@ -9,6 +9,9 @@ namespace esphome::preferences { class IntervalSyncer final : public PollingComponent { public: + // User provided, not "= default": `new(p) IntervalSyncer()` would zero-fill .bss that is already zero. + IntervalSyncer() {} + // Remove before 2027.3.0 ESPDEPRECATED("Use set_update_interval() instead. Removed in 2027.3.0", "2026.9.0") void set_write_interval(uint32_t write_interval) { this->set_update_interval(write_interval); } diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 0412d8a842..00c2e04310 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 bc256c6885..9dcbf7e686 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 380ef02304..0000000000 --- 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 7c5a0590d7..4cf88bafe9 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 885922a22a..0000000000 --- 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 9bda891efc..e523ebe756 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 ad13bdcca7..e4a7781538 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 bf5e31b48a..2b19cf8498 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 0000000000..e69de29bb2 diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp new file mode 100644 index 0000000000..f9f97606a8 --- /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 0000000000..bf13f25053 --- /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 0000000000..fd0430d85b --- /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 409de91124..c0d163c1de 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 723b21e0b0..5eb712d4cc 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 f093262e18..90e0abb14c 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 69c8a9dd6c..954fbae978 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 b9f7246b72..b3f59720ad 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 e0186be163..06091c4f1c 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 f303a14297..ebba97d1a3 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 61e7feb9af..a238603217 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 8782c255f0..d084a6c016 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 f38a035226..0e691e382d 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 2c040d0560..9971c413bd 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 d29656bab0..fe47e5ae32 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 19b8549f75..e9708d0a9f 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -1,6 +1,11 @@ +from collections.abc import Callable +from pathlib import Path +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -40,11 +45,14 @@ from esphome.const import ( CONF_ZERO, ) from esphome.core import ID, coroutine +from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] + CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" @@ -90,18 +98,102 @@ REMOTE_TRANSMITTABLE_SCHEMA = cv.Schema( ) -async def register_listener(var, config): +# Listener and dumper lists are StaticVectors sized from these counts, so every registration +# must go through add_listener / add_dumper. Every receiver's list gets the same capacity, so +# the slots are keyed by receiver and the define is the largest count any one receiver needs. +LISTENER_COUNT_DEFINE = "REMOTE_BASE_LISTENER_COUNT" +DUMPER_COUNT_DEFINE = "REMOTE_BASE_DUMPER_COUNT" + + +_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE) +_request_dumper_slot = cg.slot_counter(DUMPER_COUNT_DEFINE) + + +def add_listener(receiver: MockObj, listener: MockObj) -> None: + _request_listener_slot(str(receiver)) + cg.add(receiver.register_listener(listener)) + + +def add_dumper(receiver: MockObj, dumper: MockObj) -> None: + _request_dumper_slot(str(receiver)) + cg.add(receiver.register_dumper(dumper)) + + +async def register_listener(var: MockObj, config: ConfigType) -> None: receiver = await cg.get_variable(config[CONF_RECEIVER_ID]) - cg.add(receiver.register_listener(var)) + add_listener(receiver, var) -async def register_transmittable(var, config): - transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) +async def attach_receiver( + var: MockObj, config: ConfigType, key: str = CONF_RECEIVER_ID +) -> None: + """Link the configured receiver to an entity and register the entity as its listener. + + The C++ set_receiver() no longer registers the listener; the slot for it is counted here. + """ + receiver = await cg.get_variable(config[key]) + cg.add(var.set_receiver(receiver)) + add_listener(receiver, var) + + +async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID): + transmitter_ = await cg.get_variable(config[key]) cg.add(var.set_transmitter(transmitter_)) -def register_binary_sensor(name, type, schema): - return BINARY_SENSOR_REGISTRY.register(name, type, schema) +# Registry names that share a protocol source file +def _protocol_stem(name: str) -> str: + if name.startswith("rc_switch"): + return "rc_switch" + if name == "canalsatld": + return "canalsat" + return name + + +def protocol_define(name: str) -> str: + return f"USE_REMOTE_PROTOCOL_{_protocol_stem(name).upper()}" + + +_PROTOCOL_STEMS = sorted( + path.name.removesuffix("_protocol.cpp") + for path in Path(__file__).parent.glob("*_protocol.cpp") +) + + +def request_protocol(name: str) -> None: + """Keep a protocol's source file in the build; components using it from C++ must call this.""" + if _protocol_stem(name) not in _PROTOCOL_STEMS: + raise ValueError( + f"Unknown remote protocol {name!r}; expected one of {', '.join(_PROTOCOL_STEMS)}" + ) + cg.add_define(protocol_define(name)) + + +def _request_protocol_if_in_tree(name: str) -> None: + """Registry names from external components have no source file here and need no define.""" + if _protocol_stem(name) in _PROTOCOL_STEMS: + request_protocol(name) + + +# Only the protocol sources a configuration uses are compiled +FILTER_SOURCE_FILES = filter_source_files_from_defines( + {f"{stem}_protocol.cpp": protocol_define(stem) for stem in _PROTOCOL_STEMS} +) + + +def register_binary_sensor( + name: str, type: MockObj, schema: cv.Schema | dict +) -> Callable[[Callable[[MockObj, ConfigType], Any]], Callable]: + registerer = BINARY_SENSOR_REGISTRY.register(name, type, schema) + + def decorator(func: Callable[[MockObj, ConfigType], Any]) -> Callable: + async def new_func(var: MockObj, config: ConfigType) -> None: + _request_protocol_if_in_tree(name) + await coroutine(func)(var, config) + + return registerer(new_func) + + return decorator def register_trigger(name, type, data_type): @@ -114,6 +206,7 @@ def register_trigger(name, type, data_type): def decorator(func): async def new_func(config): + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(config[CONF_TRIGGER_ID]) await coroutine(func)(var, config) await automation.build_automation(var, [(data_type, "x")], config) @@ -131,6 +224,7 @@ def register_dumper(name, type, schema=None): def decorator(func): async def new_func(config, dumper_id): + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(dumper_id) await coroutine(func)(var, config) return var @@ -171,6 +265,7 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): + _request_protocol_if_in_tree(name) var = cg.new_Pvariable(action_id, template_arg) await register_transmittable(var, config) if CONF_REPEAT in config: @@ -213,7 +308,13 @@ DUMPER_REGISTRY = Registry() def validate_dumpers(value): if isinstance(value, str) and value.lower() == "all": return validate_dumpers(list(DUMPER_REGISTRY.keys())) - return cv.validate_registry("dumper", DUMPER_REGISTRY)(value) + entries = cv.validate_registry("dumper", DUMPER_REGISTRY)(value) + # a dumper listed twice would register twice; the receiver holds one secondary dumper + return list( + { + next(k for k in entry if k in DUMPER_REGISTRY): entry for entry in entries + }.values() + ) def validate_triggers(base_schema): @@ -1048,7 +1149,7 @@ def gobox_dumper(var, config): @register_action("gobox", GoboxAction, GOBOX_SCHEMA) async def gobox_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.int_) + template_ = await cg.templatable(config[CONF_CODE], args, cg.uint64) cg.add(var.set_code(template_)) @@ -1437,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_protocols[config] +def build_custom_rc_switch_protocol(config: ConfigType) -> MockObj: pl = config[CONF_PULSE_LENGTH] return RCSwitchBase( config[CONF_SYNC][0] * pl, @@ -1452,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, @@ -1526,7 +1643,9 @@ RC_SWITCH_TRANSMITTER = cv.Schema( } ) -rc_switch_protocols = ns.RC_SWITCH_PROTOCOLS +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) @@ -1541,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])) @@ -1552,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))) @@ -1562,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])) @@ -1573,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))) @@ -1587,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]) ) @@ -1600,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))) @@ -1612,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], @@ -1630,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( @@ -1647,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])) @@ -1658,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))) @@ -2005,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/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 7ff32923be..a309c124ee 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -191,9 +191,9 @@ class ABBWelcomeData { class ABBWelcomeProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ABBWelcomeData &src) override; - optional decode(RemoteReceiveData src) override; - void dump(const ABBWelcomeData &data) override; + void encode(RemoteTransmitData *dst, const ABBWelcomeData &src); + optional decode(RemoteReceiveData src); + void dump(const ABBWelcomeData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t data) const; diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 3f4e98bd43..98a5501155 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,9 +15,9 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const AEHAData &data) override; + void encode(RemoteTransmitData *dst, const AEHAData &data); + optional decode(RemoteReceiveData src); + void dump(const AEHAData &data); private: std::string format_data_(const std::vector &data); diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index 30b99dbeb7..ed9d6aa671 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -16,9 +16,9 @@ struct Beo4Data { class Beo4Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const Beo4Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const Beo4Data &data) override; + void encode(RemoteTransmitData *dst, const Beo4Data &data); + optional decode(RemoteReceiveData src); + void dump(const Beo4Data &data); }; DECLARE_REMOTE_PROTOCOL(Beo4) diff --git a/esphome/components/remote_base/brennenstuhl_protocol.h b/esphome/components/remote_base/brennenstuhl_protocol.h index 1d5b621714..bfea463b7d 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.h +++ b/esphome/components/remote_base/brennenstuhl_protocol.h @@ -13,9 +13,9 @@ struct BrennenstuhlData { class BrennenstuhlProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const BrennenstuhlData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const BrennenstuhlData &data) override; + void encode(RemoteTransmitData *dst, const BrennenstuhlData &data); + optional decode(RemoteReceiveData src); + void dump(const BrennenstuhlData &data); }; DECLARE_REMOTE_PROTOCOL(Brennenstuhl) diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index 674fa99ea1..c71390c267 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -21,9 +21,9 @@ struct ByronSXData { class ByronSXProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ByronSXData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ByronSXData &data) override; + void encode(RemoteTransmitData *dst, const ByronSXData &data); + optional decode(RemoteReceiveData src); + void dump(const ByronSXData &data); }; DECLARE_REMOTE_PROTOCOL(ByronSX) diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 5ba9115ea8..09bead18b3 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -19,9 +19,9 @@ struct CanalSatLDData : public CanalSatData {}; class CanalSatBaseProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const CanalSatData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const CanalSatData &data) override; + void encode(RemoteTransmitData *dst, const CanalSatData &data); + optional decode(RemoteReceiveData src); + void dump(const CanalSatData &data); protected: uint16_t frequency_; diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index d9441e8417..29a306ce29 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -21,9 +21,9 @@ struct CoolixData { class CoolixProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const CoolixData &data) override; - optional decode(RemoteReceiveData data) override; - void dump(const CoolixData &data) override; + void encode(RemoteTransmitData *dst, const CoolixData &data); + optional decode(RemoteReceiveData data); + void dump(const CoolixData &data); }; DECLARE_REMOTE_PROTOCOL(Coolix) diff --git a/esphome/components/remote_base/dish_protocol.cpp b/esphome/components/remote_base/dish_protocol.cpp index 9a6420afd5..5a89c91a50 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/dish_protocol.h b/esphome/components/remote_base/dish_protocol.h index c89f4e78e1..f319b55f43 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -13,9 +13,9 @@ struct DishData { class DishProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DishData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DishData &data) override; + void encode(RemoteTransmitData *dst, const DishData &data); + optional decode(RemoteReceiveData src); + void dump(const DishData &data); }; DECLARE_REMOTE_PROTOCOL(Dish) diff --git a/esphome/components/remote_base/dooya_protocol.h b/esphome/components/remote_base/dooya_protocol.h index 148c7c17bc..954c3cf1d3 100644 --- a/esphome/components/remote_base/dooya_protocol.h +++ b/esphome/components/remote_base/dooya_protocol.h @@ -20,9 +20,9 @@ struct DooyaData { class DooyaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DooyaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DooyaData &data) override; + void encode(RemoteTransmitData *dst, const DooyaData &data); + optional decode(RemoteReceiveData src); + void dump(const DooyaData &data); }; DECLARE_REMOTE_PROTOCOL(Dooya) diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 693a1bbe85..4e879f0f75 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -19,9 +19,9 @@ struct DraytonData { class DraytonProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DraytonData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DraytonData &data) override; + void encode(RemoteTransmitData *dst, const DraytonData &data); + optional decode(RemoteReceiveData src); + void dump(const DraytonData &data); }; DECLARE_REMOTE_PROTOCOL(Drayton) diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index 3473a489b2..663e50fb4b 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -21,9 +21,9 @@ struct DysonData { class DysonProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const DysonData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const DysonData &data) override; + void encode(RemoteTransmitData *dst, const DysonData &data); + optional decode(RemoteReceiveData src); + void dump(const DysonData &data); }; DECLARE_REMOTE_PROTOCOL(Dyson) diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index f6b278771e..0c8797af70 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -31,9 +31,9 @@ class GoboxProtocol : public RemoteProtocol { void dump_timings_(const RawTimings &timings) const; public: - void encode(RemoteTransmitData *dst, const GoboxData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const GoboxData &data) override; + void encode(RemoteTransmitData *dst, const GoboxData &data); + optional decode(RemoteReceiveData src); + void dump(const GoboxData &data); }; DECLARE_REMOTE_PROTOCOL(Gobox) diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index 9c45ba1a63..e1fd60411f 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,9 +13,9 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const HaierData &data) override; + void encode(RemoteTransmitData *dst, const HaierData &data); + optional decode(RemoteReceiveData src); + void dump(const HaierData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t item); diff --git a/esphome/components/remote_base/hob2hood_protocol.cpp b/esphome/components/remote_base/hob2hood_protocol.cpp new file mode 100644 index 0000000000..9e706b9fe6 --- /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 0000000000..44bd873a65 --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +enum Hob2HoodCommand : uint8_t { + HOB2HOOD_COMMAND_LIGHT_OFF = 0xd5, + HOB2HOOD_COMMAND_LIGHT_ON = 0xd2, + HOB2HOOD_COMMAND_FAN_OFF = 0xd8, + HOB2HOOD_COMMAND_FAN_LOW = 0x6c, + HOB2HOOD_COMMAND_FAN_MEDIUM = 0x6f, + HOB2HOOD_COMMAND_FAN_HIGH = 0xe1, + HOB2HOOD_COMMAND_FAN_MAX = 0x72, +}; + +struct Hob2HoodData { + Hob2HoodCommand command; + bool operator==(const Hob2HoodData &rhs) const { return this->command == rhs.command; } +}; + +class Hob2HoodProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const Hob2HoodData &data); + optional decode(RemoteReceiveData src); + void dump(const Hob2HoodData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Hob2Hood) + +template class Hob2HoodAction : public RemoteTransmitterActionBase { + public: + TEMPLATABLE_VALUE(Hob2HoodCommand, command) + void encode(RemoteTransmitData *dst, Ts... x) override { + Hob2HoodProtocol().encode(dst, Hob2HoodData{this->command_.value(x...)}); + } +}; + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index f6e2548dea..5911664fc3 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -14,9 +14,9 @@ struct JVCData { class JVCProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const JVCData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const JVCData &data) override; + void encode(RemoteTransmitData *dst, const JVCData &data); + optional decode(RemoteReceiveData src); + void dump(const JVCData &data); }; DECLARE_REMOTE_PROTOCOL(JVC) diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 432313b87b..335fbd164b 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -24,9 +24,9 @@ struct KeeloqData { class KeeloqProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const KeeloqData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const KeeloqData &data) override; + void encode(RemoteTransmitData *dst, const KeeloqData &data); + optional decode(RemoteReceiveData src); + void dump(const KeeloqData &data); }; DECLARE_REMOTE_PROTOCOL(Keeloq) diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index 9715974995..91dfbadb0c 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -16,9 +16,9 @@ struct LGData { class LGProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const LGData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const LGData &data) override; + void encode(RemoteTransmitData *dst, const LGData &data); + optional decode(RemoteReceiveData src); + void dump(const LGData &data); }; DECLARE_REMOTE_PROTOCOL(LG) diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index 18662ec759..f0d2410fe2 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -27,9 +27,9 @@ struct MagiQuestData { class MagiQuestProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MagiQuestData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const MagiQuestData &data) override; + void encode(RemoteTransmitData *dst, const MagiQuestData &data); + optional decode(RemoteReceiveData src); + void dump(const MagiQuestData &data); }; DECLARE_REMOTE_PROTOCOL(MagiQuest) diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 47bad6826f..85bbef1cb1 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -67,9 +67,9 @@ class MideaData { class MideaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MideaData &src) override; - optional decode(RemoteReceiveData src) override; - void dump(const MideaData &data) override; + void encode(RemoteTransmitData *dst, const MideaData &src); + optional decode(RemoteReceiveData src); + void dump(const MideaData &data); }; DECLARE_REMOTE_PROTOCOL(Midea) diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index c967e72f13..a37fb93f4f 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,9 +13,9 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const MirageData &data) override; + void encode(RemoteTransmitData *dst, const MirageData &data); + optional decode(RemoteReceiveData src); + void dump(const MirageData &data); protected: void encode_byte_(RemoteTransmitData *dst, uint8_t item); diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 7b310e8ba5..1337f7a8b3 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -14,9 +14,9 @@ struct NECData { class NECProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const NECData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const NECData &data) override; + void encode(RemoteTransmitData *dst, const NECData &data); + optional decode(RemoteReceiveData src); + void dump(const NECData &data); }; DECLARE_REMOTE_PROTOCOL(NEC) diff --git a/esphome/components/remote_base/nexa_protocol.h b/esphome/components/remote_base/nexa_protocol.h index ebcd2a2c11..ebf85387b0 100644 --- a/esphome/components/remote_base/nexa_protocol.h +++ b/esphome/components/remote_base/nexa_protocol.h @@ -24,9 +24,9 @@ class NexaProtocol : public RemoteProtocol { void zero(RemoteTransmitData *dst) const; void sync(RemoteTransmitData *dst) const; - void encode(RemoteTransmitData *dst, const NexaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const NexaData &data) override; + void encode(RemoteTransmitData *dst, const NexaData &data); + optional decode(RemoteReceiveData src); + void dump(const NexaData &data); }; DECLARE_REMOTE_PROTOCOL(Nexa) diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index d13c0f2798..84df3c08b7 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -16,9 +16,9 @@ struct PanasonicData { class PanasonicProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const PanasonicData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const PanasonicData &data) override; + void encode(RemoteTransmitData *dst, const PanasonicData &data); + optional decode(RemoteReceiveData src); + void dump(const PanasonicData &data); }; DECLARE_REMOTE_PROTOCOL(Panasonic) diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index 514ab67501..d02bd3451f 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -13,9 +13,9 @@ struct PioneerData { class PioneerProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const PioneerData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const PioneerData &data) override; + void encode(RemoteTransmitData *dst, const PioneerData &data); + optional decode(RemoteReceiveData src); + void dump(const PioneerData &data); }; DECLARE_REMOTE_PROTOCOL(Pioneer) diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index f4f6b2144d..bfd04c5cd9 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -30,9 +30,9 @@ class ProntoProtocol : public RemoteProtocol { std::string compensate_and_dump_sequence_(const RawTimings &data, uint16_t timebase); public: - void encode(RemoteTransmitData *dst, const ProntoData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ProntoData &data) override; + void encode(RemoteTransmitData *dst, const ProntoData &data); + optional decode(RemoteReceiveData src); + void dump(const ProntoData &data); }; DECLARE_REMOTE_PROTOCOL(Pronto) diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index dbb89e41c6..f6f0f33c6e 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -14,9 +14,9 @@ struct RC5Data { class RC5Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RC5Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RC5Data &data) override; + void encode(RemoteTransmitData *dst, const RC5Data &data); + optional decode(RemoteReceiveData src); + void dump(const RC5Data &data); }; DECLARE_REMOTE_PROTOCOL(RC5) diff --git a/esphome/components/remote_base/rc6_protocol.h b/esphome/components/remote_base/rc6_protocol.h index fda9d98ecb..c4a2e8529b 100644 --- a/esphome/components/remote_base/rc6_protocol.h +++ b/esphome/components/remote_base/rc6_protocol.h @@ -15,9 +15,9 @@ struct RC6Data { class RC6Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RC6Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RC6Data &data) override; + void encode(RemoteTransmitData *dst, const RC6Data &data); + optional decode(RemoteReceiveData src); + void dump(const RC6Data &data); }; DECLARE_REMOTE_PROTOCOL(RC6) diff --git a/esphome/components/remote_base/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index 612558ca1c..bdd6615d43 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -1,29 +1,25 @@ #include "rc_switch_protocol.h" + +#include +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::remote_base { static const char *const TAG = "remote.rc_switch"; -const RCSwitchBase RC_SWITCH_PROTOCOLS[9] = {RCSwitchBase(0, 0, 0, 0, 0, 0, false), - RCSwitchBase(350, 10850, 350, 1050, 1050, 350, false), - RCSwitchBase(650, 6500, 650, 1300, 1300, 650, false), - RCSwitchBase(3000, 7100, 400, 1100, 900, 600, false), - RCSwitchBase(380, 2280, 380, 1140, 1140, 380, false), - RCSwitchBase(3000, 7000, 500, 1000, 1000, 500, false), - RCSwitchBase(10350, 450, 450, 900, 900, 450, true), - RCSwitchBase(300, 9300, 150, 900, 900, 150, false), - RCSwitchBase(250, 2500, 250, 1250, 250, 250, false)}; +RCSwitchBase rc_switch_protocol(uint8_t index) { + // 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; + return rc_switch_protocol_copy(&RC_SWITCH_PROTOCOLS[index]); +} -RCSwitchBase::RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, - uint32_t one_high, uint32_t one_low, bool inverted) - : sync_high_(sync_high), - sync_low_(sync_low), - zero_high_(zero_high), - zero_low_(zero_low), - one_high_(one_high), - one_low_(one_low), - inverted_(inverted) {} +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol) { + RCSwitchBase copy; + progmem_memcpy(©, protocol, sizeof(copy)); + return copy; +} void RCSwitchBase::one(RemoteTransmitData *dst) const { if (!this->inverted_) { @@ -133,11 +129,11 @@ bool RCSwitchBase::decode(RemoteReceiveData &src, uint64_t *out_data, uint8_t *o optional RCSwitchBase::decode(RemoteReceiveData &src) const { RCSwitchData out; uint8_t out_nbits; - for (uint8_t i = 1; i <= 8; i++) { + for (size_t i = 1; i < std::size(RC_SWITCH_PROTOCOLS); i++) { src.reset(); const RCSwitchBase *protocol = &RC_SWITCH_PROTOCOLS[i]; if (protocol->decode(src, &out.code, &out_nbits) && out_nbits >= 3) { - out.protocol = i; + out.protocol = static_cast(i); return out; } } @@ -240,13 +236,13 @@ 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_); } bool RCSwitchDumper::dump(RemoteReceiveData src) { - for (uint8_t i = 1; i <= 8; i++) { + for (size_t i = 1; i < std::size(RC_SWITCH_PROTOCOLS); i++) { src.reset(); uint64_t out_data; uint8_t out_nbits; @@ -257,7 +253,7 @@ bool RCSwitchDumper::dump(RemoteReceiveData src) { buffer[j] = (out_data & ((uint64_t) 1 << (out_nbits - j - 1))) ? '1' : '0'; buffer[out_nbits] = '\0'; - ESP_LOGI(TAG, "Received RCSwitch Raw: protocol=%u data='%s'", i, buffer); + ESP_LOGI(TAG, "Received RCSwitch Raw: protocol=%u data='%s'", static_cast(i), buffer); // only send first decoded protocol return true; diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index 3224c04fb2..a102da3ff6 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" @@ -16,9 +18,16 @@ class RCSwitchBase { public: using ProtocolData = RCSwitchData; - RCSwitchBase() = default; - RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, uint32_t one_high, - uint32_t one_low, bool inverted); + constexpr RCSwitchBase() = default; + constexpr RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, + uint32_t one_high, uint32_t one_low, bool inverted) + : sync_high_(sync_high), + sync_low_(sync_low), + zero_high_(zero_high), + zero_low_(zero_low), + one_high_(one_high), + one_low_(one_low), + inverted_(inverted) {} void one(RemoteTransmitData *dst) const; @@ -58,10 +67,34 @@ class RCSwitchBase { uint32_t zero_low_{}; uint32_t one_high_{}; uint32_t one_low_{}; - bool inverted_{}; + uint32_t inverted_{}; // bool widened so every field is a word: the table is read from flash + + // 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"); }; -extern const RCSwitchBase RC_SWITCH_PROTOCOLS[9]; +// Constant-initialized and kept in flash on every platform. The decoder reads entries in place +// through a pointer, which ESP8266 only allows while every field is a whole word; copies out of +// the table go through rc_switch_protocol() +static_assert(sizeof(RCSwitchBase) == 7 * sizeof(uint32_t), "RCSwitchBase must stay word-only for flash reads"); +inline constexpr RCSwitchBase RC_SWITCH_PROTOCOLS[] PROGMEM = { + {0, 0, 0, 0, 0, 0, false}, + {350, 10850, 350, 1050, 1050, 350, false}, + {650, 6500, 650, 1300, 1300, 650, false}, + {3000, 7100, 400, 1100, 900, 600, false}, + {380, 2280, 380, 1140, 1140, 380, false}, + {3000, 7000, 500, 1000, 1000, 500, false}, + {10350, 450, 450, 900, 900, 450, true}, + {300, 9300, 150, 900, 900, 150, false}, + {250, 2500, 250, 1250, 250, 250, false}, +}; + +/// RAM copy of RC_SWITCH_PROTOCOLS[index] (0 when out of range) for the transmit actions and the dumper, made with +/// progmem_memcpy so no byte load ever touches the flash table on ESP8266 +RCSwitchBase rc_switch_protocol(uint8_t index); +/// 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); @@ -175,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); @@ -203,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 4d9bc55f21..a685b733b4 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -99,29 +99,48 @@ bool RemoteReceiverBinarySensorBase::on_receive(RemoteReceiveData src) { /* RemoteReceiverBase */ +// Slots are counted at code generation; a registration from C++ setup() has none +#ifdef REMOTE_BASE_LISTENER_COUNT +void RemoteReceiverBase::register_listener(RemoteReceiverListener *listener) { + if (this->listeners_.size() == REMOTE_BASE_LISTENER_COUNT) { + ESP_LOGE(TAG, "No %s slot: register it from to_code() with remote_base.add_%s", LOG_STR_LITERAL("listener"), + LOG_STR_LITERAL("listener")); + return; + } + this->listeners_.push_back(listener); +} +#endif + +#ifdef REMOTE_BASE_DUMPER_COUNT void RemoteReceiverBase::register_dumper(RemoteReceiverDumperBase *dumper) { if (dumper->is_secondary()) { - this->secondary_dumpers_.push_back(dumper); - } else { + if (this->secondary_dumper_ == nullptr) { + this->secondary_dumper_ = dumper; + return; + } + } else if (this->dumpers_.size() != REMOTE_BASE_DUMPER_COUNT) { this->dumpers_.push_back(dumper); + return; } + ESP_LOGE(TAG, "No %s slot: register it from to_code() with remote_base.add_%s", LOG_STR_LITERAL("dumper"), + LOG_STR_LITERAL("dumper")); } +#endif -void RemoteReceiverBase::call_listeners_() { +void RemoteReceiverBase::call_listeners_dumpers_() { +#ifdef REMOTE_BASE_LISTENER_COUNT for (auto *listener : this->listeners_) listener->on_receive(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); -} - -void RemoteReceiverBase::call_dumpers_() { +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT bool success = false; for (auto *dumper : this->dumpers_) { if (dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_))) success = true; } - if (!success) { - for (auto *dumper : this->secondary_dumpers_) - dumper->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); - } + if (!success && this->secondary_dumper_ != nullptr) + this->secondary_dumper_->dump(RemoteReceiveData(this->temp_, this->tolerance_, this->tolerance_mode_)); +#endif } void RemoteReceiverBinarySensorBase::dump_config() { LOG_BINARY_SENSOR("", "Remote Receiver Binary Sensor", this); } @@ -163,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]; @@ -194,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 4e2ed4b71c..36d2a44c50 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -1,12 +1,14 @@ +#pragma once + +#include #include #include -#pragma once - #include "esphome/components/binary_sensor/binary_sensor.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" namespace esphome::remote_base { @@ -141,29 +143,56 @@ 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. +template +concept RemoteProtocolDecoder = requires(T proto, RemoteReceiveData src) { + { proto.decode(src) } -> std::same_as>; +}; +template +concept RemoteProtocolDumper = RemoteProtocolDecoder && requires(T proto, const typename T::ProtocolData &data) { + proto.dump(data); +}; +template +concept RemoteProtocolEncoder = requires(T proto, RemoteTransmitData *dst, const typename T::ProtocolData &data) { + proto.encode(dst, data); +}; + class RemoteTransmitterBase : public RemoteComponentBase { public: RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} 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 Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + template + void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { auto call = this->transmit(); Protocol().encode(call.get_data(), data); call.set_send_times(send_times); @@ -172,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_; @@ -194,24 +239,37 @@ class RemoteReceiverDumperBase { class RemoteReceiverBase : public RemoteComponentBase { public: RemoteReceiverBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} - void register_listener(RemoteReceiverListener *listener) { this->listeners_.push_back(listener); } + // Slots are counted at code generation; without one the call fails at compile time with the same message + // the runtime check logs +#ifdef REMOTE_BASE_LISTENER_COUNT + void register_listener(RemoteReceiverListener *listener); +#else + template void register_listener(T *) { + static_assert(sizeof(T) == 0, "No listener slot: register it from to_code() with remote_base.add_listener"); + } +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT void register_dumper(RemoteReceiverDumperBase *dumper); +#else + template void register_dumper(T *) { + static_assert(sizeof(T) == 0, "No dumper slot: register it from to_code() with remote_base.add_dumper"); + } +#endif void set_tolerance(uint32_t tolerance, ToleranceMode tolerance_mode) { this->tolerance_ = tolerance; this->tolerance_mode_ = tolerance_mode; } protected: - void call_listeners_(); - void call_dumpers_(); - void call_listeners_dumpers_() { - this->call_listeners_(); - this->call_dumpers_(); - } + void call_listeners_dumpers_(); - std::vector listeners_; - std::vector dumpers_; - std::vector secondary_dumpers_; +#ifdef REMOTE_BASE_LISTENER_COUNT + StaticVector listeners_; +#endif +#ifdef REMOTE_BASE_DUMPER_COUNT + StaticVector dumpers_; + RemoteReceiverDumperBase *secondary_dumper_{nullptr}; // runs only when no primary dumper matched +#endif RawTimings temp_; uint32_t tolerance_{25}; ToleranceMode tolerance_mode_{TOLERANCE_MODE_PERCENTAGE}; @@ -229,15 +287,14 @@ class RemoteReceiverBinarySensorBase : public binary_sensor::BinarySensorInitial /* TEMPLATES */ +// Protocols are used only through their concrete type (see the RemoteProtocol* concepts); encode/decode/dump +// stay non-virtual so unused ones link out template class RemoteProtocol { public: using ProtocolData = T; - virtual void encode(RemoteTransmitData *dst, const ProtocolData &data) = 0; - virtual optional decode(RemoteReceiveData src) = 0; - virtual void dump(const ProtocolData &data) = 0; }; -template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { +template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { public: RemoteReceiverBinarySensor() : RemoteReceiverBinarySensorBase() {} @@ -255,7 +312,7 @@ template class RemoteReceiverBinarySensor : public RemoteReceiverBin T::ProtocolData data_; }; -template +template class RemoteReceiverTrigger final : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { @@ -276,8 +333,8 @@ class RemoteTransmittable { void set_transmitter(RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } protected: - template - void transmit_(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + template + void transmit_(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { this->transmitter_->transmit(data, send_times, send_wait); } RemoteTransmitterBase *transmitter_; @@ -298,7 +355,7 @@ template class RemoteTransmitterActionBase : public RemoteTransm virtual void encode(RemoteTransmitData *dst, Ts... x) = 0; }; -template class RemoteReceiverDumper : public RemoteReceiverDumperBase { +template class RemoteReceiverDumper : public RemoteReceiverDumperBase { public: bool dump(RemoteReceiveData src) override { auto proto = T(); diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index 3582dac398..8db025f812 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -12,9 +12,9 @@ struct RoombaData { class RoombaProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const RoombaData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const RoombaData &data) override; + void encode(RemoteTransmitData *dst, const RoombaData &data); + optional decode(RemoteReceiveData src); + void dump(const RoombaData &data); }; DECLARE_REMOTE_PROTOCOL(Roomba) diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index 4f15d906e7..df4e1af8d8 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -16,9 +16,9 @@ struct Samsung36Data { class Samsung36Protocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const Samsung36Data &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const Samsung36Data &data) override; + void encode(RemoteTransmitData *dst, const Samsung36Data &data); + optional decode(RemoteReceiveData src); + void dump(const Samsung36Data &data); }; DECLARE_REMOTE_PROTOCOL(Samsung36) diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index bb234d681d..dfa22ff85c 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -14,9 +14,9 @@ struct SamsungData { class SamsungProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SamsungData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SamsungData &data) override; + void encode(RemoteTransmitData *dst, const SamsungData &data); + optional decode(RemoteReceiveData src); + void dump(const SamsungData &data); }; DECLARE_REMOTE_PROTOCOL(Samsung) diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index eb873e8b7d..f83b2908b6 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -16,9 +16,9 @@ struct SonyData { class SonyProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SonyData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SonyData &data) override; + void encode(RemoteTransmitData *dst, const SonyData &data); + optional decode(RemoteReceiveData src); + void dump(const SonyData &data); }; DECLARE_REMOTE_PROTOCOL(Sony) diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 7caf5eab86..40a5c2daec 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -17,9 +17,9 @@ struct SymphonyData { class SymphonyProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const SymphonyData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const SymphonyData &data) override; + void encode(RemoteTransmitData *dst, const SymphonyData &data); + optional decode(RemoteReceiveData src); + void dump(const SymphonyData &data); }; DECLARE_REMOTE_PROTOCOL(Symphony) diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index 8a853005ac..35d5af314c 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -14,9 +14,9 @@ struct ToshibaAcData { class ToshibaAcProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const ToshibaAcData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const ToshibaAcData &data) override; + void encode(RemoteTransmitData *dst, const ToshibaAcData &data); + optional decode(RemoteReceiveData src); + void dump(const ToshibaAcData &data); }; DECLARE_REMOTE_PROTOCOL(ToshibaAc) diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 285c9f2125..8e965a5c73 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -16,9 +16,9 @@ struct TotoData { class TotoProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const TotoData &data) override; - optional decode(RemoteReceiveData src) override; - void dump(const TotoData &data) override; + void encode(RemoteTransmitData *dst, const TotoData &data); + optional decode(RemoteReceiveData src); + void dump(const TotoData &data); }; DECLARE_REMOTE_PROTOCOL(Toto) diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 6e8c73d331..866e108131 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -112,17 +112,21 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.Required(CONF_PIN): cv.All(pins.internal_gpio_input_pin_schema), cv.Optional(CONF_DUMP, default=[]): remote_base.validate_dumpers, cv.Optional(CONF_TOLERANCE, default="25%"): validate_tolerance, + # pulse ring targets hold one 4 byte entry per pulse; 4000b keeps their 1000 pulses cv.SplitDefault( CONF_BUFFER_SIZE, - esp32="10000b", - esp32_c2="1000b", - esp32_c61="1000b", - esp8266="1000b", - bk72xx="1000b", - ln882x="1000b", - rtl87xx="1000b", - rp2="1000b", - ): cv.validate_bytes, + esp32=cv.UNDEFINED, + # the pulse ring needs a size; only RMT targets size themselves in setup() + **{ + f"esp32_{variant.removeprefix('ESP32').lower()}": "4000b" + for variant in esp32_rmt.VARIANTS_NO_RMT + }, + esp8266="4000b", + bk72xx="4000b", + ln882x="4000b", + rtl87xx="4000b", + rp2="4000b", + ): cv.All(cv.validate_bytes, cv.int_range(min=64, max=65535)), cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, cv.Range(max=TimePeriod(microseconds=4294967295)), @@ -221,11 +225,11 @@ async def to_code(config: ConfigType) -> None: dumpers = await remote_base.build_dumpers(config[CONF_DUMP]) for dumper in dumpers: - cg.add(var.register_dumper(dumper)) + remote_base.add_dumper(var, dumper) triggers = await remote_base.build_triggers(config) for trigger in triggers: - cg.add(var.register_listener(trigger)) + remote_base.add_listener(var, trigger) await cg.register_component(var, config) cg.add( @@ -233,7 +237,8 @@ async def to_code(config: ConfigType) -> None: config[CONF_TOLERANCE][CONF_VALUE], config[CONF_TOLERANCE][CONF_TYPE] ) ) - cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) + if CONF_BUFFER_SIZE in config: + cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE])) cg.add(var.set_filter_us(config[CONF_FILTER])) cg.add(var.set_idle_us(config[CONF_IDLE])) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index bbcb7ae765..b3e4649096 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -14,7 +14,7 @@ static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_ int32_t multiplier = ((int32_t) level << 1) - 1; uint32_t buffer_write = arg->buffer_write; arg->buffer[buffer_write++] = (int32_t) delta * multiplier; - if (buffer_write >= arg->buffer_size) { + if (buffer_write >= arg->buffer_entries) { buffer_write = 0; } @@ -65,8 +65,9 @@ void RemoteReceiverComponent::setup() { this->store_.idle_us = this->idle_us_; this->store_.filter_us = this->filter_us_; this->store_.pin = this->pin_->to_isr(); - this->store_.buffer = new int32_t[this->buffer_size_]; - this->store_.buffer_size = this->buffer_size_; + // rounded up so a size that is not a multiple of four never holds less than requested + this->store_.buffer_entries = (this->buffer_size_ + sizeof(int32_t) - 1) / sizeof(int32_t); + this->store_.buffer = new int32_t[this->store_.buffer_entries]; this->store_.prev_micros = micros(); this->store_.commit_micros = this->store_.prev_micros; this->store_.prev_level = this->pin_->digital_read(); @@ -79,11 +80,11 @@ void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG( TAG, "Remote Receiver:\n" - " Buffer Size: %" PRIu32 "\n" + " Buffer Size: %" PRIu32 " bytes (%" PRIu32 " pulses)\n" " Tolerance: %" PRIu32 "%s\n" " Filter out pulses shorter than: %" PRIu32 " us\n" " Signal is done after %" PRIu32 " us of no changes", - this->buffer_size_, this->tolerance_, + this->buffer_size_, this->store_.buffer_entries, this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); @@ -119,7 +120,7 @@ void RemoteReceiverComponent::loop() { while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) { reserve_size++; temp_read++; - if (temp_read >= s.buffer_size) { + if (temp_read >= s.buffer_entries) { temp_read = 0; } } @@ -129,7 +130,7 @@ void RemoteReceiverComponent::loop() { // read the buffer for (uint32_t i = 0; i < reserve_size + 1; i++) { this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]); - if (s.buffer_read >= s.buffer_size) { + if (s.buffer_read >= s.buffer_entries) { s.buffer_read = 0; } } diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index f9ec054fe3..1c1c473f47 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -30,7 +30,7 @@ struct RemoteReceiverComponentStore { uint32_t buffer_read{0}; volatile uint32_t commit_micros{0}; volatile uint32_t prev_micros{0}; - uint32_t buffer_size{1000}; + uint32_t buffer_entries{0}; uint32_t filter_us{10}; uint32_t idle_us{10000}; ISRInternalGPIOPin pin; @@ -47,7 +47,7 @@ struct RemoteReceiverComponentStore { /// The position last read from volatile uint32_t buffer_read{0}; bool overflow{false}; - uint32_t buffer_size{1000}; + uint32_t buffer_size{0}; uint32_t receive_size{0}; uint32_t filter_symbols{0}; esp_err_t error{ESP_OK}; @@ -83,14 +83,14 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, protected: #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void decode_rmt_(rmt_symbol_word_t *item, size_t item_count); + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); rmt_channel_handle_t channel_{NULL}; uint32_t filter_symbols_{0}; uint32_t receive_symbols_{0}; - bool with_dma_{false}; uint32_t carrier_frequency_{0}; + bool with_dma_{false}; uint8_t carrier_duty_percent_{100}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; #endif #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_ESP32) @@ -101,7 +101,7 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, HighFrequencyLoopRequester high_freq_; #endif - uint32_t buffer_size_{}; + uint32_t buffer_size_{}; // 0 on RMT targets: sized from receive_symbols in setup() uint32_t filter_us_{10}; uint32_t idle_us_{10000}; }; diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 632ca9763a..64392aa7ee 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -1,5 +1,6 @@ #include "remote_receiver.h" #include "esphome/core/log.h" +#include "esphome/core/wake.h" #ifdef USE_ESP32 #include @@ -10,28 +11,41 @@ namespace esphome::remote_receiver { static const char *const TAG = "remote_receiver"; +static constexpr uint32_t DEFAULT_BUFFER_SLOTS = 4; static bool IRAM_ATTR HOT rmt_callback(rmt_channel_handle_t channel, const rmt_rx_done_event_data_t *event, void *arg) { RemoteReceiverComponentStore *store = (RemoteReceiverComponentStore *) arg; - rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + store->buffer_write); + const uint32_t buffer_write = store->buffer_write; + rmt_rx_done_event_data_t *event_buffer = (rmt_rx_done_event_data_t *) (store->buffer + buffer_write); uint32_t event_size = sizeof(rmt_rx_done_event_data_t); - uint32_t next_write = store->buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); + uint32_t next_write = buffer_write + event_size + event->num_symbols * sizeof(rmt_symbol_word_t); if (next_write + event_size + store->receive_size > store->buffer_size) { next_write = 0; } if (store->buffer_read - next_write < event_size + store->receive_size) { - next_write = store->buffer_write; + next_write = buffer_write; store->overflow = true; } if (event->num_symbols <= store->filter_symbols) { - next_write = store->buffer_write; + next_write = buffer_write; } store->error = rmt_receive(channel, (uint8_t *) store->buffer + next_write + event_size, store->receive_size, &store->config); event_buffer->num_symbols = event->num_symbols; event_buffer->received_symbols = event->received_symbols; + const bool stored = next_write != buffer_write; store->buffer_write = next_write; - return false; + // a stored frame is decoded, and a failed re-arm reported, on the next loop pass instead of + // waiting out the loop interval; filtered noise and dropped frames leave nothing to read + BaseType_t task_woken = pdFALSE; + if (stored || store->error != ESP_OK) + wake_loop_isrsafe(&task_woken); + return task_woken != pdFALSE; +} + +void RemoteReceiverComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); } void RemoteReceiverComponent::setup() { @@ -46,13 +60,8 @@ void RemoteReceiverComponent::setup() { channel.flags.with_dma = this->with_dma_; esp_err_t error = rmt_new_rx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_rx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_rx_channel")); return; } if (this->pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -62,9 +71,7 @@ void RemoteReceiverComponent::setup() { } error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } @@ -76,9 +83,7 @@ void RemoteReceiverComponent::setup() { carrier.flags.polarity_active_low = this->pin_->is_inverted(); error = rmt_apply_carrier(this->channel_, &carrier); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } @@ -88,9 +93,7 @@ void RemoteReceiverComponent::setup() { callbacks.on_recv_done = rmt_callback; error = rmt_rx_register_event_callbacks(this->channel_, &callbacks, &this->store_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_rx_register_event_callbacks"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_rx_register_event_callbacks")); return; } @@ -104,46 +107,44 @@ void RemoteReceiverComponent::setup() { this->store_.config.signal_range_max_ns = this->idle_us_ * 1000; this->store_.filter_symbols = this->filter_symbols_; this->store_.receive_size = this->receive_symbols_ * sizeof(rmt_symbol_word_t); - this->store_.buffer_size = std::max((event_size + this->store_.receive_size) * 2, this->buffer_size_); + // one slot per pending rmt_receive; two are the floor (one filling while one is decoded), and + // the default of four covers a few frames queued across a stalled loop pass + const uint32_t slot_size = event_size + this->store_.receive_size; + this->store_.buffer_size = + this->buffer_size_ != 0 ? std::max(slot_size * 2, this->buffer_size_) : slot_size * DEFAULT_BUFFER_SLOTS; this->store_.buffer = new uint8_t[this->store_.buffer_size]; error = rmt_receive(this->channel_, (uint8_t *) this->store_.buffer + event_size, this->store_.receive_size, &this->store_.config); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_receive"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_receive")); return; } } void RemoteReceiverComponent::dump_config() { - ESP_LOGCONFIG(TAG, - "Remote Receiver:\n" - " Clock resolution: %" PRIu32 " hz\n" - " RMT symbols: %" PRIu32 "\n" - " Filter symbols: %" PRIu32 "\n" - " Receive symbols: %" PRIu32 "\n" - " Tolerance: %" PRIu32 "%s\n" - " Carrier frequency: %" PRIu32 " hz\n" - " Carrier duty: %u%%\n" - " Filter out pulses shorter than: %" PRIu32 " us\n" - " Signal is done after %" PRIu32 " us of no changes", - this->clock_resolution_, this->rmt_symbols_, this->filter_symbols_, this->receive_symbols_, - this->tolerance_, (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", - this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); + ESP_LOGCONFIG( + TAG, + "Remote Receiver:\n" + " Clock resolution: %" PRIu32 " hz\n" + " RMT symbols: %" PRIu32 "\n" + " Filter symbols: %" PRIu32 "\n" + " Receive symbols: %" PRIu32 "\n" + " Buffer size: %" PRIu32 " bytes\n" + " Tolerance: %" PRIu32 "%s\n" + " Carrier frequency: %" PRIu32 " hz\n" + " Carrier duty: %u%%\n" + " Filter out pulses shorter than: %" PRIu32 " us\n" + " Signal is done after %" PRIu32 " us of no changes", + this->clock_resolution_, this->rmt_symbols_, this->filter_symbols_, this->receive_symbols_, + this->store_.buffer_size, this->tolerance_, + (this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? LOG_STR_LITERAL(" us") : LOG_STR_LITERAL("%"), + this->carrier_frequency_, this->carrier_duty_percent_, this->filter_us_, this->idle_us_); LOG_PIN(" Pin: ", this->pin_); - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteReceiverComponent::loop() { if (this->store_.error != ESP_OK) { - ESP_LOGE(TAG, "Receive error"); - this->error_code_ = this->store_.error; - this->error_string_ = "in rmt_callback"; - this->mark_failed(); + this->fail_(this->store_.error, LOG_STR("in rmt_callback")); } if (this->store_.overflow) { ESP_LOGW(TAG, "Buffer overflow"); diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 58392c48ab..68eac315d5 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 a1b0926451..0000000000 --- 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 5e82213a48..29ef9bc2cc 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 4db4e80a60..fad273a118 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) @@ -141,6 +151,8 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa #endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED + // log the failed RMT call and mark the component failed + void fail_(esp_err_t error, const LogString *reason); void configure_rmt_(); void wait_for_rmt_(); @@ -156,8 +168,6 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa bool eot_level_{false}; rmt_channel_handle_t channel_{NULL}; rmt_encoder_handle_t encoder_{NULL}; - esp_err_t error_code_{ESP_OK}; - std::string error_string_; bool inverted_{false}; bool non_blocking_{false}; #endif diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp index fad91f593f..26e9a1baa3 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 3c9a12d472..91bd339f21 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -51,6 +51,11 @@ static size_t IRAM_ATTR HOT encoder_callback(const void *data, size_t size, size } #endif +void RemoteTransmitterComponent::fail_(esp_err_t error, const LogString *reason) { + ESP_LOGE(TAG, "RMT driver failed: %s", esp_err_to_name(error)); + this->mark_failed(reason); +} + void RemoteTransmitterComponent::setup() { this->inverted_ = this->pin_->is_inverted(); this->configure_rmt_(); @@ -67,11 +72,6 @@ void RemoteTransmitterComponent::dump_config() { if (this->current_carrier_frequency_ != 0 && this->carrier_duty_percent_ != 100) { ESP_LOGCONFIG(TAG, " Carrier Duty: %u%%", this->carrier_duty_percent_); } - - if (this->is_failed()) { - ESP_LOGE(TAG, "Configuring RMT driver failed: %s (%s)", esp_err_to_name(this->error_code_), - this->error_string_.c_str()); - } } void RemoteTransmitterComponent::digital_write(bool value) { @@ -129,13 +129,8 @@ void RemoteTransmitterComponent::configure_rmt_() { #endif error = rmt_new_tx_channel(&channel, &this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - if (error == ESP_ERR_NOT_FOUND) { - this->error_string_ = "out of RMT symbol memory"; - } else { - this->error_string_ = "in rmt_new_tx_channel"; - } - this->mark_failed(); + this->fail_(error, + error == ESP_ERR_NOT_FOUND ? LOG_STR("out of RMT symbol memory") : LOG_STR("in rmt_new_tx_channel")); return; } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) @@ -159,9 +154,7 @@ void RemoteTransmitterComponent::configure_rmt_() { encoder.min_chunk_size = 1; error = rmt_new_simple_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_simple_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_simple_encoder")); return; } #else @@ -169,18 +162,14 @@ void RemoteTransmitterComponent::configure_rmt_() { memset(&encoder, 0, sizeof(encoder)); error = rmt_new_copy_encoder(&encoder, &this->encoder_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_new_copy_encoder"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_new_copy_encoder")); return; } #endif error = rmt_enable(this->channel_); if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_enable"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_enable")); return; } this->digital_write(open_drain || this->inverted_); @@ -199,9 +188,7 @@ void RemoteTransmitterComponent::configure_rmt_() { error = rmt_apply_carrier(this->channel_, &carrier); } if (error != ESP_OK) { - this->error_code_ = error; - this->error_string_ = "in rmt_apply_carrier"; - this->mark_failed(); + this->fail_(error, LOG_STR("in rmt_apply_carrier")); return; } } @@ -213,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(); @@ -271,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; } @@ -286,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_(); }); @@ -297,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(); @@ -341,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)); @@ -353,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 6db9faac36..799f46401c 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 e69de29bb2..b9b070e015 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 0000000000..0a8d0c1ca3 --- /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 0000000000..0dcd523380 --- /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 0000000000..780f22e429 --- /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 7de468cb50..8fa8aeb61c 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/restart/button/restart_button.h b/esphome/components/restart/button/restart_button.h index 974db0cec4..4baac6472c 100644 --- a/esphome/components/restart/button/restart_button.h +++ b/esphome/components/restart/button/restart_button.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartButton final : public button::Button, public Component { public: + // User provided, not "= default": `new(p) RestartButton()` would zero-fill .bss that is already zero. + RestartButton() {} + void dump_config() override; protected: diff --git a/esphome/components/restart/switch/restart_switch.h b/esphome/components/restart/switch/restart_switch.h index dc9ec8eadc..03cf03f166 100644 --- a/esphome/components/restart/switch/restart_switch.h +++ b/esphome/components/restart/switch/restart_switch.h @@ -7,6 +7,9 @@ namespace esphome::restart { class RestartSwitch final : public switch_::Switch, public Component { public: + // User provided, not "= default": `new(p) RestartSwitch()` would zero-fill .bss that is already zero. + RestartSwitch() {} + void dump_config() override; protected: diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index 9863379b79..fed8365e9e 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 cbb1880ec5..200ef97810 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 9604290cf0..dfcc53ebb2 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 62094899d7..ed2d4b4345 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 286267baed..0d2f069c42 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 72722ec4b1..ab5442e676 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 18311416c3..f568aedcbd 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 31f3f90630..2ce4c1a906 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 dae7df26c3..23346ca6f4 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -197,20 +197,21 @@ def _parse_platform_version(value: Any) -> str: # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 0, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 1) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -# develop-branch commit carrying the arduino-pico 6.0.0 / pico-quick-toolchain -# 5.0.0 (GCC 16.1) update; replace with a release tag when one is cut -RECOMMENDED_ARDUINO_PLATFORM_VERSION = "9c167c6b8aac4f4cfa6d55a0c4e5b848795150c0" +# develop-branch commit carrying the arduino-pico 6.1.0 update; it predates +# 6.1.1, so the boards that release adds have no board JSON on it yet; replace +# with a release tag when one is cut +RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1" def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { - "dev": (cv.Version(6, 0, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(6, 0, 0), None), + "dev": (cv.Version(6, 1, 1), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 1, 1), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index 4b2f9769b0..08536fd053 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1135,6 +1135,18 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "soldered_nula_node_rp2040": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "soldered_nula_rp2350": { "MISO": 2, "MOSI": 3, @@ -1430,6 +1442,19 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "waveshare_rp2350_core": { + "LED": 39, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "waveshare_rp2350_lcd_0_96": { "MISO": 16, "MOSI": 19, @@ -1467,6 +1492,18 @@ RP2_BOARD_PINS = { "SS": 17, "TX": 0, }, + "waveshare_rp2350_poe_eth": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, "waveshare_rp2350_zero": { "MISO": 4, "MOSI": 3, @@ -2127,6 +2164,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "soldered_nula_node_rp2040": { + "name": "Soldered Electronics NULA Node", + "mcu": "rp2040", + "max_pin": 29, + "wifi": True, + }, "soldered_nula_rp2350": { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", @@ -2268,6 +2311,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "waveshare_rp2350_core": { + "name": "Waveshare RP2350 Core", + "mcu": "rp2350", + "max_pin": 47, + "die": "B", + }, "waveshare_rp2350_lcd_0_96": { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", @@ -2286,6 +2335,12 @@ BOARDS = { "max_pin": 29, "die": "A", }, + "waveshare_rp2350_poe_eth": { + "name": "Waveshare RP2350-POE-ETH", + "mcu": "rp2350", + "max_pin": 29, + "die": "A", + }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", diff --git a/esphome/components/rp2/gpio.cpp b/esphome/components/rp2/gpio.cpp index 0dbb124a26..b95da9bcdb 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 8af57924a2..adf45eeaf7 100644 --- a/esphome/components/rp2040_ble/btstack_memory.cpp +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -20,7 +20,7 @@ namespace esphome::rp2040_ble { namespace { -// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// Pinned against arduino-pico 6.1.1's prebuilt archives: a framework bump (or // a changed ENABLE_* macro) shifting the struct layout must fail the build // here, not overrun the pool blocks at runtime. Sizes differ per core // architecture (measured from each archive's own storage symbols). GCC only: diff --git a/esphome/components/rp2040_pwm/output.py b/esphome/components/rp2040_pwm/output.py index a0344e8054..49b10f7d20 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 8263113168..7808e2af01 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 b744aee31c..12857a556f 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 a3cede236a..7c64975077 100644 --- a/esphome/components/rp2_ble_tracker/automation.h +++ b/esphome/components/rp2_ble_tracker/automation.h @@ -37,11 +37,6 @@ template class StartScanAction final : public Action, pub } }; -template class StopScanAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->stop_scan(); } -}; - } // namespace esphome::rp2_ble_tracker #endif // USE_RP2 diff --git a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp index c0afc0607e..f2f25741f3 100644 --- a/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp +++ b/esphome/components/rpi_dpi_rgb/rpi_dpi_rgb.cpp @@ -101,7 +101,7 @@ int RpiDpiRgb::get_height() { } void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -124,7 +124,7 @@ void RpiDpiRgb::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void RpiDpiRgb::dump_config() { diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index b6c4183586..4f4d17b441 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 256bdce5f2..055204571e 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 9db8490415..368c103d15 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 72bd81a06e..60dbee39bf 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 ef92d0d707..5c14d39807 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 1ea9c457f3..abbda5872b 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 0000000000..e69de29bb2 diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp new file mode 100644 index 0000000000..fee2c83a5f --- /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 0000000000..9c53fe6971 --- /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 0000000000..e8ffe772ab --- /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 0c738a9b78..2ab3a5ed2b 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 40d10e9f6b..2082c48759 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 9bc8a263c8..5d03ed70a8 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 e2858dff34..0000000000 --- a/esphome/components/safe_mode/automation.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include "esphome/core/automation.h" -#include "safe_mode.h" - -namespace esphome::safe_mode { - -template class MarkSuccessfulAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->mark_successful(); } -}; - -} // namespace esphome::safe_mode diff --git a/esphome/components/safe_mode/safe_mode.h b/esphome/components/safe_mode/safe_mode.h index 0633c92a78..903d9eb79f 100644 --- a/esphome/components/safe_mode/safe_mode.h +++ b/esphome/components/safe_mode/safe_mode.h @@ -17,6 +17,9 @@ constexpr uint32_t RTC_KEY = 233825507UL; /// SafeModeComponent provides a safe way to recover from repeated boot failures class SafeModeComponent final : public Component { public: + // User provided, not "= default": `new(p) SafeModeComponent()` would zero-fill .bss that is already zero. + SafeModeComponent() {} + bool should_enter_safe_mode(uint8_t num_attempts, uint32_t enable_time, uint32_t boot_is_good_after, bool in_flash); /// Set to true if the next startup will enter safe mode diff --git a/esphome/components/scd30/automation.h b/esphome/components/scd30/automation.h deleted file mode 100644 index a816ae1f26..0000000000 --- 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 37789100f7..f3f4ede8c9 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 4746c0c879..0000000000 --- a/esphome/components/scd4x/automation.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "scd4x.h" - -namespace esphome::scd4x { - -template -class PerformForcedCalibrationAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { - if (this->value_.has_value()) { - this->parent_->perform_forced_calibration(this->value_.value(x...)); - } - } - - protected: - TEMPLATABLE_VALUE(uint16_t, value) -}; - -template class FactoryResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->factory_reset(); } -}; - -} // namespace esphome::scd4x diff --git a/esphome/components/scd4x/sensor.py b/esphome/components/scd4x/sensor.py index af3ff3a7af..71be6e43b1 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 e92850fd63..2c79e9ed0a 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 61bca5bc28..bfe96e1b23 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 63d0ff7cb3..97b27af498 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/sdl/sdl_esphome.cpp b/esphome/components/sdl/sdl_esphome.cpp index 03fc086021..a764b74581 100644 --- a/esphome/components/sdl/sdl_esphome.cpp +++ b/esphome/components/sdl/sdl_esphome.cpp @@ -164,7 +164,7 @@ void Sdl::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t * } void Sdl::draw_pixel_at(int x, int y, Color color) { - if (this->texture_ == nullptr || !this->get_clipping().inside(x, y)) + if (this->texture_ == nullptr || this->is_point_clipped(x, y)) return; if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { diff --git a/esphome/components/sdm_meter/sensor.py b/esphome/components/sdm_meter/sensor.py index 8d7e981d93..74c56acf12 100644 --- a/esphome/components/sdm_meter/sensor.py +++ b/esphome/components/sdm_meter/sensor.py @@ -160,29 +160,13 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await modbus.register_modbus_client_device(var, config) - if CONF_TOTAL_POWER in config: - sens = await sensor.new_sensor(config[CONF_TOTAL_POWER]) - cg.add(var.set_total_power_sensor(sens)) - - if CONF_FREQUENCY in config: - sens = await sensor.new_sensor(config[CONF_FREQUENCY]) - cg.add(var.set_frequency_sensor(sens)) - - if CONF_IMPORT_ACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_IMPORT_ACTIVE_ENERGY]) - cg.add(var.set_import_active_energy_sensor(sens)) - - if CONF_EXPORT_ACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_EXPORT_ACTIVE_ENERGY]) - cg.add(var.set_export_active_energy_sensor(sens)) - - if CONF_IMPORT_REACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_IMPORT_REACTIVE_ENERGY]) - cg.add(var.set_import_reactive_energy_sensor(sens)) - - if CONF_EXPORT_REACTIVE_ENERGY in config: - sens = await sensor.new_sensor(config[CONF_EXPORT_REACTIVE_ENERGY]) - cg.add(var.set_export_reactive_energy_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_POWER, var.set_total_power_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_IMPORT_ACTIVE_ENERGY, var.set_import_active_energy_sensor) + await sensors(CONF_EXPORT_ACTIVE_ENERGY, var.set_export_active_energy_sensor) + await sensors(CONF_IMPORT_REACTIVE_ENERGY, var.set_import_reactive_energy_sensor) + await sensors(CONF_EXPORT_REACTIVE_ENERGY, var.set_export_reactive_energy_sensor) for i, phase in enumerate([CONF_PHASE_A, CONF_PHASE_B, CONF_PHASE_C]): if phase not in config: diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index 1c222e5e80..dfc7857266 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -106,8 +106,6 @@ void SDS011Component::loop() { } } -void SDS011Component::set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } - void SDS011Component::sds011_write_command_(const uint8_t *command_data) { this->write_byte(SDS011_MSG_HEAD); this->write_byte(SDS011_COMMAND_ID_REQUEST); diff --git a/esphome/components/sds011/sds011.h b/esphome/components/sds011/sds011.h index 4f4571ab69..0a896cdc4c 100644 --- a/esphome/components/sds011/sds011.h +++ b/esphome/components/sds011/sds011.h @@ -12,7 +12,7 @@ class SDS011Component final : public Component, public uart::UARTDevice { SDS011Component() = default; /// Manually set the rx-only mode. Defaults to false. - void set_rx_mode_only(bool rx_mode_only); + void set_rx_mode_only(bool rx_mode_only) { this->rx_mode_only_ = rx_mode_only; } void set_pm_2_5_sensor(sensor::Sensor *pm_2_5_sensor) { pm_2_5_sensor_ = pm_2_5_sensor; } void set_pm_10_0_sensor(sensor::Sensor *pm_10_0_sensor) { pm_10_0_sensor_ = pm_10_0_sensor; } diff --git a/esphome/components/sds011/sensor.py b/esphome/components/sds011/sensor.py index e05d3b02e6..fe1481df9a 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 121eb2b4b3..98f3995ed9 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 3386118bcf..58c62652b0 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 d01618b0e6..0dc6d07a96 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 9d46dee6f6..208040e9e0 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 36ee2c0087..e2e45656ce 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 f9588d783e..76e8a2089b 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 8f284cb20a..2f926b1eda 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 4130bac224..b009de8381 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 a2f41a90a8..d815aa1a8c 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 63bd02acd0..e1dc428997 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 82f0fc9aea..582c72bebe 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 6d8864455f..0b3359b71c 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 0d4e0fafc5..04aef00d46 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 f561c030a4..fa266fc81f 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 8e5da893ad..c2dc752ca5 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 67a5118646..23d1f47cce 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 e1b261bc96..47997ac7f2 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 21d938c4fe..0000000000 --- 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 e86c8bf899..7bfce0f26a 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 c1970ab132..42dae464ed 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -2,20 +2,27 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg -from esphome.components import esp32, network, psram, socket, wifi +from esphome.components import esp32, mdns, network, psram, socket, wifi +from esphome.components.const import CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_ESPHOME, CONF_FORMAT, CONF_HEIGHT, CONF_ID, + CONF_MDNS, + CONF_MODEL, + CONF_NAME, + CONF_PROJECT, CONF_SAMPLE_RATE, CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_VERSION, CONF_WIDTH, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType # mdns for autodiscovery @@ -27,6 +34,13 @@ DOMAIN = "sendspin" CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" +CONF_FIRMWARE_VERSION = "firmware_version" + +# An empty device information string would be sent to the server as an empty value rather than +# falling back, so reject it instead of silently substituting the fallback. The 127 byte cap keeps +# the length prefix of a protobuf string field to a single byte, matching `esphome: project:`. +DEVICE_INFO_STRING = cv.All(cv.string_strict, cv.Length(min=1), cv.ByteLength(max=127)) + CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" @@ -95,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 @@ -198,6 +205,9 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): cv.declare_id(SendspinHub), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, + cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, + cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, + cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, } ), cv.only_on_esp32, @@ -223,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: @@ -248,11 +248,32 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_task_stack_in_psram(True)) psram.request_external_task_stack() + # Device information for the server's client/hello message. Falls back to the project + # information, which is written as `manufacturer.model`. Anything still unset keeps the + # default the hub itself applies: the ESPHome name and version. + project = CORE.config[CONF_ESPHOME].get(CONF_PROJECT, {}) + project_manufacturer, _, project_model = project.get(CONF_NAME, "").partition(".") + for value, setter in ( + (config.get(CONF_MANUFACTURER) or project_manufacturer, var.set_manufacturer), + (config.get(CONF_MODEL) or project_model, var.set_model), + ( + config.get(CONF_FIRMWARE_VERSION) or project.get(CONF_VERSION), + var.set_firmware_version, + ), + ): + if value: + cg.add(setter(value)) + # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2") + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") cg.add_define("USE_SENDSPIN", True) # for MDNS + # Service starts disabled and the hub enables it; always advertised where unsupported + if mdns.request_service_enable_disable(): + mdns_var = await cg.get_variable(CORE.config[CONF_MDNS][CONF_ID]) + cg.add(var.set_mdns(mdns_var)) + data = _get_data() # The color role is not yet wired up in ESPHome; disable it in the library for now. diff --git a/esphome/components/sendspin/automation.h b/esphome/components/sendspin/automation.h deleted file mode 100644 index 0b408b1235..0000000000 --- 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 3c6c82b009..e26e8204aa 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 154e62a4b2..0000000000 --- a/esphome/components/sendspin/image/automation.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) - -#include "esphome/core/automation.h" -#include "sendspin_image.h" - -namespace esphome::sendspin_ { - -template -class SendspinImageTransitionFinishedAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->transition_finished(); } -}; - -} // namespace esphome::sendspin_ - -#endif diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index fe0bda6f42..59ead1bb53 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -97,6 +97,10 @@ void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) { // Ignore any commands sent before the media player is setup return; } + if (!this->parent_->is_client_running()) { + ESP_LOGW(TAG, "Cannot control media player: Sendspin is disabled"); + return; + } auto volume = call.get_volume(); if (volume.has_value()) { diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6a9f1f18ba..743d0bb62c 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 f5c35f107a..0000000000 --- a/esphome/components/sendspin/media_source/automations.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "esphome/core/defines.h" - -#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) - -#include "esphome/core/automation.h" -#include "sendspin_media_source.h" - -namespace esphome::sendspin_ { - -template -class EnableStaticDelayAdjustmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); } -}; - -template -class DisableStaticDelayAdjustmentAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); } -}; - -} // namespace esphome::sendspin_ - -#endif diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index 88ff234e83..c3fb1fe1cb 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -45,6 +45,8 @@ bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri. // THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only) bool SendspinMediaSource::play_uri(const std::string &uri) { + // The queued request has been delivered, whatever the outcome, so the next stream start may request again + this->pending_start_ = false; if (!this->is_ready() || this->is_failed() || !this->has_listener()) { return false; } @@ -54,6 +56,11 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { return false; } + if (!this->parent_->is_client_running()) { + ESP_LOGE(TAG, "Cannot play '%s': Sendspin is disabled", uri.c_str()); + return false; + } + if (!uri.starts_with(URI_PREFIX)) { ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str()); return false; @@ -74,7 +81,6 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { } // Tell the orchestrator we're now playing so it routes audio output from us - this->pending_start_ = false; this->set_state_(media_source::MediaSourceState::PLAYING); return true; @@ -82,6 +88,15 @@ bool SendspinMediaSource::play_uri(const std::string &uri) { // THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only) void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) { + if (!this->parent_->is_client_running()) { + if (command == media_source::MediaSourceCommand::STOP) { + // Nothing is playing, so the orchestrator gets its pipeline back straight away + this->on_stream_end(); + } else { + ESP_LOGW(TAG, "Cannot handle command: Sendspin is disabled"); + } + return; + } switch (command) { case media_source::MediaSourceCommand::STOP: { if (!this->pending_start_) { diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 028491284a..15d358d50f 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -62,22 +62,38 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - if (!this->client_->start_server()) { - ESP_LOGE(TAG, "Failed to start Sendspin server"); - this->mark_failed(); - return; - } +#ifndef USE_SENDSPIN_SWITCH + this->enabled_ = true; +#endif } -void SendspinHub::loop() { this->client_->loop(); } +void SendspinHub::loop() { + if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && + !this->status_has_error()) { + if (!this->enabled_.value()) { + this->client_->stop(); + } else if (!this->client_->start()) { + this->status_set_error(LOG_STR("Failed to start Sendspin client")); + } + } + this->client_->loop(); + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + this->update_mdns_service_(); +#endif +} void SendspinHub::dump_config() { char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Sendspin Hub:\n" " Client ID: %s\n" + " Manufacturer: %s\n" + " Model: %s\n" + " Firmware version: %s\n" " Task stack in PSRAM: %s", - get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), + this->firmware_version_, YESNO(this->task_stack_in_psram_)); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -92,25 +108,54 @@ void SendspinHub::dump_config() { #endif } +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +void SendspinHub::set_enabled(bool enabled) { + if (this->status_has_error()) { + ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", + enabled ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + return; + } + this->enabled_ = enabled; +} + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +// THREAD CONTEXT: Main loop +void SendspinHub::update_mdns_service_() { + // Synced from loop() because mdns sets up after this hub and only builds its service list then. + if (!this->mdns_->is_ready()) { + return; + } + bool advertise = this->client_->is_started(); + if (advertise == this->mdns_advertised_) { + return; + } + // One attempt per change + this->mdns_advertised_ = advertise; + if (!this->mdns_->set_service_enabled("_sendspin", "_tcp", advertise)) { + ESP_LOGE(TAG, "Failed to %s mDNS service", advertise ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable")); + } +} +#endif + // --- Delegating methods --- // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::connect_to_server(const std::string &url) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->connect_to(url); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->disconnect(reason); } } // THREAD CONTEXT: Main loop (invoked from Sendspin components) void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_ready()) { + if (this->is_client_running()) { this->client_->update_state(state); } } @@ -127,15 +172,19 @@ const char *SendspinHub::get_client_id_into_buffer(std::spanmodel_ != nullptr ? this->model_ : App.get_name().c_str(); +} + sendspin::SendspinClientConfig SendspinHub::build_client_config_() { sendspin::SendspinClientConfig config; char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf); config.name = App.get_friendly_name(); - config.product_name = App.get_name(); - config.manufacturer = "ESPHome"; - config.software_version = ESPHOME_VERSION; + config.product_name = this->get_product_name_(); + config.manufacturer = this->manufacturer_; + config.software_version = this->firmware_version_; config.httpd_psram_stack = this->task_stack_in_psram_; return config; @@ -225,7 +274,7 @@ void SendspinHub::artwork_frame_done(uint8_t slot) { // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, std::optional mute) { - if (this->is_ready()) { + if (this->is_client_running()) { sendspin::ClientCommandControllerObject obj = { .command = command, .volume = volume, @@ -235,6 +284,13 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman } } +// THREAD CONTEXT: Main loop (invoked from the sendspin.switch action) +void SendspinHub::switch_client() { + // Clear any EXTERNAL_SOURCE state so the switch command is followed + this->update_state(sendspin::SendspinClientState::SYNCHRONIZED); + this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); +} + // THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 7c50c3eb80..ea09dafe68 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -8,6 +8,11 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/version.h" + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#include "esphome/components/mdns/mdns_component.h" +#endif #include #include @@ -92,7 +97,7 @@ class SendspinHub final : public Component, /// @brief Connects the underlying client to the given Sendspin server. /// - /// No-op if the hub's client is not ready (e.g. setup() has not completed). + /// No-op if the hub's client is not running (see is_client_running()). /// Must be called from the main loop thread. /// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`). void connect_to_server(const std::string &url); @@ -100,7 +105,7 @@ class SendspinHub final : public Component, /// @brief Disconnects the underlying client from the current server. /// /// Sends a `client/goodbye` message with the given reason before closing the connection. - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param reason Reason reported to the server: /// - `ANOTHER_SERVER`: client is switching to another server. /// - `SHUTDOWN`: client is shutting down. @@ -110,7 +115,7 @@ class SendspinHub final : public Component, /// @brief Updates the client's reported playback state on the server. /// - /// No-op if the hub's client is not ready. Must be called from the main loop thread. + /// No-op if the hub's client is not running. Must be called from the main loop thread. /// @param state New client state: /// - `SYNCHRONIZED`: client is synchronized and playing from the server. /// - `ERROR`: client encountered a playback error. @@ -125,6 +130,30 @@ class SendspinHub final : public Component, void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } + /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or + /// stop. + /// + /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks + /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has + /// called this once. Must be called from the main loop thread. + void set_enabled(bool enabled); + + /// @brief Returns whether the Sendspin client is running. + bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } + + /// @brief Sets the device information reported to the server in the `client/hello` message. + /// + /// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the + /// lifetime of the hub. Only called for values the configuration overrides; anything left alone + /// keeps the default described on the member below. + void set_manufacturer(const char *manufacturer) { this->manufacturer_ = manufacturer; } + void set_model(const char *model) { this->model_ = model; } + void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; } + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } +#endif + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -152,6 +181,9 @@ class SendspinHub final : public Component, void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); + /// @brief Sends the SWITCH controller command; exposed as the sendspin.switch action. + void switch_client(); + template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } @@ -187,10 +219,18 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// @brief Returns the product name reported to the server: the configured model, or the device name. + const char *get_product_name_() const; + /// @brief Writes the active network interface's MAC into @p buf and returns its data pointer. /// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi). static const char *get_client_id_into_buffer(std::span buf); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. + void update_mdns_service_(); +#endif + // --- SendspinClientListener overrides --- void on_group_update(const sendspin::GroupUpdateObject &group) override; @@ -268,6 +308,22 @@ 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_; + + // Device information sent in the `client/hello` message. Defaults apply when neither the + // sendspin configuration nor the project information supplies a value. + const char *manufacturer_{"ESPHome"}; + const char *model_{nullptr}; // nullptr reports the device name instead + const char *firmware_version_{ESPHOME_VERSION}; + +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + mdns::MDNSComponent *mdns_{nullptr}; +#endif }; /// @brief Base class for all sendspin subcomponents. diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py new file mode 100644 index 0000000000..1f896640bf --- /dev/null +++ b/esphome/components/sendspin/switch/__init__.py @@ -0,0 +1,37 @@ +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 CONF_TYPE, ENTITY_CATEGORY_CONFIG +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) + +CONFIG_SCHEMA = cv.All( + 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, +) + + +async def to_code(config: ConfigType) -> None: + var = await switch.new_switch(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + cg.add_define("USE_SENDSPIN_SWITCH", True) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp new file mode 100644 index 0000000000..0bf029d4c7 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -0,0 +1,26 @@ +#include "sendspin_switch.h" + +#ifdef USE_ESP32 + +#include "esphome/core/log.h" + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.switch"; + +void SendspinSwitch::setup() { + // The hub waits for this request, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinSwitch::write_state(bool state) { + this->parent_->set_enabled(state); + this->publish_state(state); +} + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h new file mode 100644 index 0000000000..253d952b22 --- /dev/null +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/switch/switch.h" + +namespace esphome::sendspin_ { + +/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). +class SendspinSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +} // namespace esphome::sendspin_ + +#endif // USE_ESP32 diff --git a/esphome/components/senseair/senseair.h b/esphome/components/senseair/senseair.h index 48154a53d9..b376f1f657 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 82368a60d0..2494e9a6d1 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 f6ff4711d4..3325c27772 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 558fbdbb12..1b1cd2ba15 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 3b632a1847..f3293d4ed5 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 35a4a29e0d..fdd20461e7 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 dbd6f4d34b..9b9d7c033a 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/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 59e011932b..bee5d7c6d3 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -40,10 +40,7 @@ const LogString *state_class_to_string(StateClass state_class) { return StateClassStrings::get_log_str(static_cast(state_class), 0); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" -Sensor::Sensor() : state(NAN), raw_state(NAN) {} -#pragma GCC diagnostic pop +Sensor::Sensor() : state(NAN) {} int8_t Sensor::get_accuracy_decimals() { if (this->sensor_flags_.has_accuracy_override) @@ -66,11 +63,8 @@ StateClass Sensor::get_state_class() { } void Sensor::publish_state(float state) { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - this->raw_state = state; -#pragma GCC diagnostic pop #ifdef USE_SENSOR_FILTER + this->raw_state_ = state; this->raw_callback_.call(state); #endif diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index f4ea4af985..20288fa88e 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -96,18 +96,20 @@ class Sensor : public EntityBase { /// Getter-syntax for .state. float get_state() const { return this->state; } - /// Getter-syntax for .raw_state + /// Get the last state received by publish_state(), before any filters were applied. float get_raw_state() const { -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - return this->raw_state; -#pragma GCC diagnostic pop +#ifdef USE_SENSOR_FILTER + return this->raw_state_; +#else + return this->state; // No filters compiled in, raw == filtered +#endif } /** Publish a new state to the front-end. * - * First, the new state will be assigned to the raw_value. Then it's passed through all filters - * until it finally lands in the .value member variable and a callback is issued. + * The value is passed through the filter chain (when filters are compiled in) before landing in + * the `state` member and triggering the state callback. The pre-filter value is available via + * get_raw_state(). * * @param state The state as a floating point number. */ @@ -137,17 +139,11 @@ class Sensor : public EntityBase { */ float state; -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wdeprecated-declarations" - /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.10.0. - ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.10.0", "2026.4.0") - float raw_state; -#pragma GCC diagnostic pop - void internal_send_state_to_frontend(float state); protected: #ifdef USE_SENSOR_FILTER + float raw_state_{NAN}; ///< The last state passed to publish_state(), before filters. LazyCallbackManager raw_callback_; ///< Storage for raw state callbacks. #endif LazyCallbackManager callback_; ///< Storage for filtered state callbacks. diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index 4186fcf8b1..a876728050 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"] @@ -30,6 +32,7 @@ MULTI_CONF = True serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy") SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice) +SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap") api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums") SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType") @@ -37,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" @@ -72,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.""" @@ -87,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 c1c1510643..16e0f0b984 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) { @@ -29,26 +38,63 @@ void SerialProxy::setup() { #ifdef USE_API // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; +#endif +#ifdef USE_SERIAL_PROXY_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 + // the loop enabled is what lets that finish; without it the tap would stall until a + // client happened to subscribe. + if (this->tap_ != nullptr && this->tap_->tap_needs_port()) { + return; + } #endif // No subscriber at startup; disable loop until a client subscribes this->disable_loop(); } -void SerialProxy::loop() { -#ifdef USE_API - // Safety check — loop should only run when subscribed, but guard against races - if (this->api_connection_ == nullptr) [[unlikely]] { - this->disable_loop(); +#ifdef USE_SERIAL_PROXY_TAP +void SerialProxy::reset_mode_() { + // The mode belongs to a session, not to the port. Carrying a departed client's choice + // over to the next one would inject protocol bytes into a stream that never asked for + // them -- a firmware upload, or any client built before this request existed and so + // unable to turn it off. Guessing RAW is the safe direction: a client that wanted + // protocol handling and did not ask for it merely sends its own acknowledgements. + if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) { return; } + ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_); + this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW; +} +#endif +void SerialProxy::loop() { +#ifdef USE_API // Detect subscriber disconnect - if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() || - !api_is_connected()) { + if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() || + !this->api_connection_->is_connection_setup() || !api_is_connected())) { ESP_LOGW(TAG, "Subscriber disconnected"); this->api_connection_ = nullptr; + this->reset_mode_(); + } + + // With no subscriber there is normally nothing to do, but a tap may still need the port + // read -- it does its protocol work precisely while nobody else is listening. + if (this->api_connection_ == nullptr) [[unlikely]] { +#ifdef USE_SERIAL_PROXY_TAP + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + return; + } +#else this->disable_loop(); return; +#endif } // Read available data from UART and forward to subscribed client @@ -69,11 +115,54 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) { if (!this->read_array(buffer, to_read)) return; +#ifdef USE_SERIAL_PROXY_TAP + // Before forwarding, so a tap that answers the device (an acknowledgement, say) is not + // waiting on the network round trip to a subscriber that may not even exist. + if (this->tap_observing_()) { + this->tap_->on_device_rx(buffer, to_read); + } +#endif + + if (this->api_connection_ == nullptr) { + return; + } this->outgoing_msg_.set_data(buffer, to_read); this->api_connection_->send_serial_proxy_data(this->outgoing_msg_); } #endif +#ifdef USE_SERIAL_PROXY_TAP + +bool SerialProxy::tap_observing_() const { + if (this->tap_ == nullptr) { + return false; + } + // With no subscriber, a tap doing its own protocol work (the boot-time handshake with + // the device, say) is served regardless of mode -- nobody has chosen one yet. Once a + // subscriber holds the port, the mode alone decides, so RAW stays inert. + if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) { + return true; + } + // Otherwise the mode decides. RAW must be inert: a client that flips to RAW before + // flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes + // into it, and "the tap turned out not to recognise the stream" is not good enough. + return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL; +} + +void SerialProxy::tap_pump() { +#ifdef USE_API + // Nothing would consume the bytes; leave them in the FIFO + if (!this->tap_observing_() && this->api_connection_ == nullptr) { + return; + } + const size_t available = this->available(); + if (available > 0) { + this->read_and_send_(available); + } +#endif +} +#endif + void SerialProxy::dump_config() { ESP_LOGCONFIG(TAG, "Serial Proxy [%" PRIu32 "]:\n" @@ -82,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")); } @@ -92,8 +182,9 @@ void SerialProxy::dump_config() { SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity, uint8_t stop_bits, uint8_t data_size) { #ifdef USE_API - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -159,24 +250,115 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } +SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection, + api::enums::SerialProxyMode mode) { +#ifdef USE_API + // Only the live subscriber may change the mode, so the mode cannot outlive a session + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; + } +#endif + // Values come from a remote client + if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) { + ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast(mode)); + return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT; + } + // PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows +#ifdef USE_SERIAL_PROXY_TAP + const bool has_tap = this->tap_ != nullptr; +#else + const bool has_tap = false; +#endif + if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) { + ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_); + return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED; + } + ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_, + mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW")); +#ifdef USE_SERIAL_PROXY_TAP + const bool leaving_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; + this->mode_ = mode; + + // Only for an explicit client request, not for reset_mode_() at the end of a session: + // an ordinary disconnect says nothing about the device, whereas a client deliberately + // asking for raw bytes usually precedes changing what the device is. + if (leaving_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_disabled(); + } +#endif + return SerialProxyResult::SERIAL_PROXY_RESULT_OK; +} + void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) { #ifdef USE_API - // Bytes from a client other than the live subscriber would interleave with the - // subscriber's traffic on the wire - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_); + // Bytes from anyone but the live subscriber would interleave with the subscriber's + // traffic -- or with an active tap's -- on the wire + if (!this->is_subscriber_(api_connection)) { + if (this->api_connection_ != nullptr) { + ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_); + } else { + // A legacy client streaming writes without subscribing would flood WARN, one per + // request; writes are the only high-rate, unacknowledged operation, so keep this + // visible without drowning the log + ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_); + } return; } #endif if (data == nullptr || len == 0) return; + // 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 + // After the write, so the tap observes the same ordering the device does + if (this->tap_observing_()) { + this->tap_->on_client_tx(data, len); + } +#endif +} + +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->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]", + this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -202,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); @@ -210,8 +446,8 @@ uint32_t SerialProxy::get_modem_pins() const { SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { #ifdef USE_API // Flushing stalls the port, so it gets the same ownership check as writes - if (this->port_claimed_by_other_(api_connection)) { - ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_); + if (!this->is_subscriber_(api_connection)) { + ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } #endif @@ -230,11 +466,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) { } #ifdef USE_API -bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const { - return this->api_connection_ != nullptr && this->api_connection_ != api_connection && - this->api_connection_->is_connection_setup(); -} - SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection, api::enums::SerialProxyRequestType type) { switch (type) { @@ -252,6 +483,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE; } ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription"); + // End the dead client's session before starting the new one, so its mode + // cannot leak into a session that never asked for it + this->api_connection_ = nullptr; + this->reset_mode_(); } this->api_connection_ = api_connection; this->enable_loop(); @@ -264,7 +499,15 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn return SerialProxyResult::SERIAL_PROXY_RESULT_OK; } this->api_connection_ = nullptr; + this->reset_mode_(); +#ifdef USE_SERIAL_PROXY_TAP + // Keep the loop alive for a tap that still needs the port (mirrors loop()) + if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) { + this->disable_loop(); + } +#else this->disable_loop(); +#endif ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_); return SerialProxyResult::SERIAL_PROXY_RESULT_OK; default: diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index a0e47ee686..1a406bc089 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -20,12 +20,22 @@ #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; namespace enums { enum SerialProxyPortType : uint32_t; enum SerialProxyRequestType : uint32_t; +enum SerialProxyMode : uint32_t; } // namespace enums } // namespace esphome::api @@ -52,6 +62,41 @@ 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. +/// +/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without +/// the pipe knowing anything about the protocol: the tap is compiled in only when some +/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it. +/// +/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes +/// flowing in either direction, so a misbehaving tap cannot corrupt the stream. +class SerialProxyTap { + public: + /// Bytes read from the device, before they are forwarded to any subscriber. + virtual void on_device_rx(const uint8_t *data, size_t len) = 0; + + /// Bytes a subscriber sent towards the device, after they have been written. + virtual void on_client_tx(const uint8_t *data, size_t len) = 0; + + /// True when the port must keep reading even with no subscriber attached, so a tap can + /// do its own protocol work while nobody is listening. Honoured only while no + /// subscriber holds the port; with one attached, the port mode alone decides. + virtual bool tap_needs_port() const = 0; + + /// A client explicitly turned protocol handling off for this port. Distinct from the + /// automatic reset when a session ends: this one means a client intends to do something + /// else with the device -- reflash it, most likely -- so anything the tap believes about + /// it should be treated as suspect. + virtual void on_protocol_disabled() = 0; +}; +#endif + class SerialProxy final : public uart::UARTDevice, public Component { public: void setup() override; @@ -77,6 +122,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Get the port type api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; } + /// Handle a mode change requested by an API client + SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode); + /// Configure UART parameters and apply them /// @param api_connection The API connection requesting the change /// @param baudrate Baud rate in bits per second @@ -121,18 +169,108 @@ 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; } + + /// Write bytes originating from the tap rather than from a client. Bypasses the + /// subscriber ownership check, but only while the tap is being served bytes -- so a + /// port in RAW mode with a subscriber attached stays inert. Returns false when the + /// bytes were dropped for that reason. + bool write_from_tap(const uint8_t *data, size_t len) { + if (!this->tap_observing_()) { + return false; + } + this->write_array(data, len); + return true; + } + + /// Whether the tap is currently being served bytes. Can flip false with no callback + /// (a subscriber attaching in RAW mode, say), so a tap should check before starting + /// protocol work and when a reply seems overdue. + bool tap_is_observed() const { return this->tap_observing_(); } + + /// Resume reading after a tap's needs change. loop() disables itself when there is + /// neither a subscriber nor a tap that wants the port, so a tap starting fresh work + /// must ask for it back. Must be called from the main loop. + void tap_request_port() { this->enable_loop(); } + + /// Whether the underlying device is present. On a USB UART this tracks enumeration, so + /// a tap can notice the device being unplugged and plugged back in. + bool is_device_connected() const { return this->parent_->is_connected(); } + + /// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the + /// main loop is running -- during setup, for instance, while a component is still + /// blocking on can_proceed(). Must not be called from on_device_rx() or + /// on_client_tx(): each nested cycle costs a 256-byte stack frame. + void tap_pump(); +#endif + protected: #ifdef USE_API - /// Read from UART and send to API client (slow path with 256-byte stack buffer) + /// Read from UART, hand the bytes to any tap, and forward them to a subscriber + /// (slow path with a 256-byte stack buffer) void read_and_send_(size_t available); - /// True when a live subscriber other than the given connection holds the port - bool port_claimed_by_other_(api::APIConnection *api_connection) const; + /// True when the given connection is the live subscriber. Every port operation + /// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed + /// client can never share the wire with the subscriber or an active tap. + bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } +#endif + + /// 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_(); +#else + /// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is + /// nothing to reset + void reset_mode_() {} +#endif + +#ifdef USE_SERIAL_PROXY_TAP + /// True when the tap should be shown the traffic passing through this port + bool tap_observing_() const; +#endif + +#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}; @@ -147,6 +285,11 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Port type api::enums::SerialProxyPortType port_type_{}; +#ifdef USE_SERIAL_PROXY_TAP + /// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW + api::enums::SerialProxyMode mode_{}; +#endif + /// Optional GPIO pins for modem control GPIOPin *rts_pin_{nullptr}; GPIOPin *dtr_pin_{nullptr}; @@ -154,6 +297,18 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Current modem pin states 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 666c7dbcdd..74acaa3496 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 156dab6dc1..ce29c783e2 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 beb549ce70..b56df3fb2e 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 1a88d190e4..bf8fbe1a97 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 eadca15050..baee146a22 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 bda48c07ce..d6ca2b131b 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 ae7ee6fa59..c472e01f83 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 1cf0b4e1b8..c2bfa94fd5 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 010e325df4..cc2ebb93a7 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 276131cfed..bbe8e14cf7 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 e0f2b7b280..4245283ab4 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 7ba7da801c..dae98bc189 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 bf561a0e0d..1404263ce6 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 0000000000..5650c1c404 --- /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 0000000000..382f649976 --- /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 995f87710e..096ec48eb3 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 bb670e639f..4d923f6475 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 edebed5d40..3d2cd8ef50 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 0d4284f145..cec3b3498a 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 528d201799..f4597f2522 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 0000000000..f2f66edc05 --- /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 0000000000..d2316cd064 --- /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 0000000000..628efb32d9 --- /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 8d00dbede2..b411445b98 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 917b5b2f7a..ed8057e289 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 a6bd639c10..f87c5377e4 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 212da80312..f6d1daa124 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 eb8870786d..9d7dedbbbf 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 0000000000..9812f1835e --- /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 0000000000..f77ee05055 --- /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 0000000000..e1f00e2927 --- /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 0000000000..a42080e641 --- /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 d217534041..c8e31226ef 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 94c18421ba..008b8d6226 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 c995c2c087..e084020a14 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 98b5abe58c..d0a743543e 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 443588a04c..a44b79f05e 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 90eb19d73d..159af15808 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -1,7 +1,5 @@ """Speaker Media Player Setup.""" -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import ( @@ -24,7 +22,6 @@ from esphome.const import ( CONF_BUFFER_SIZE, CONF_FILES, CONF_FORMAT, - CONF_ID, CONF_NUM_CHANNELS, CONF_ON_TURN_OFF, CONF_ON_TURN_ON, @@ -33,9 +30,6 @@ from esphome.const import ( CONF_TASK_STACK_IN_PSRAM, ) -_LOGGER = logging.getLogger(__name__) - - AUTO_LOAD = ["audio"] DEPENDENCIES = ["network"] @@ -44,7 +38,7 @@ DOMAIN = "media_player" CONF_ANNOUNCEMENT = "announcement" CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline" -CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0 +CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2027.4.0 CONF_ENQUEUE = "enqueue" CONF_MEDIA_FILE = "media_file" CONF_MEDIA_PIPELINE = "media_pipeline" @@ -68,11 +62,6 @@ AUDIO_PIPELINE_TYPE_ENUM = { "ANNOUNCEMENT": AudioPipelineType.ANNOUNCEMENT, } -PlayOnDeviceMediaAction = speaker_ns.class_( - "PlayOnDeviceMediaAction", - automation.Action, - cg.Parented.template(SpeakerMediaPlayer), -) StopStreamAction = speaker_ns.class_( "StopStreamAction", automation.Action, cg.Parented.template(SpeakerMediaPlayer) ) @@ -103,15 +92,6 @@ def _validate_repeated_speaker(config): def _final_validate(config): - # Remove before 2026.10.0 - if CONF_CODEC_SUPPORT_ENABLED in config: - _LOGGER.warning( - "'%s' is deprecated and will be removed in 2026.10.0. " - "Codec support is now automatically determined from the pipeline " - "'format' setting. Set format to 'NONE' to enable all codecs.", - CONF_CODEC_SUPPORT_ENABLED, - ) - # Request codecs based on pipeline formats. Codecs needed by local files are # already requested during CONFIG_SCHEMA validation (via audio_files_schema). media_player.request_codecs_for_format_configs( @@ -151,8 +131,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range( min=4000, max=4000000 ), - # Remove before 2026.10.0 - cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string), + # Removed in 2026.10.0 - kept to provide helpful error message + cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.invalid( + "The 'codec_support_enabled' option has been removed in ESPHome 2026.10.0.\n" + "Codec support is now determined from the pipeline 'format' setting.\n" + "Set 'format: NONE' on the pipeline to enable all codecs." + ), cv.Optional(CONF_FILES): audio_file.audio_files_schema(), cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage, @@ -249,9 +233,8 @@ async def to_code(config): audio_file.generate_audio_file_code(file_config) -@automation.register_action( +automation.register_apply_action( "media_player.speaker.play_on_device_media_file", - PlayOnDeviceMediaAction, cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(SpeakerMediaPlayer), @@ -261,17 +244,18 @@ async def to_code(config): }, key=CONF_MEDIA_FILE, ), - synchronous=True, + automation.ApplyCall( + "play_file({}, {}, {})", + ( + # The file is a use_id of a global AudioFile pointer; global-scope qualified + # so a trigger arg with the same name cannot shadow it. + ( + CONF_MEDIA_FILE, + audio.AudioFile.operator("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 509984cfa2..f150a48573 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 02dad15de9..b114a7b7a5 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 f9e2127993..0000000000 --- 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 fe994f440d..ab98935bda 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; @@ -595,8 +617,11 @@ void SpeakerMediaPlayer::set_mute_state_(bool mute_state) { } void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit with in the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); if (this->media_speaker_ != nullptr) { this->media_speaker_->set_volume(bounded_volume); @@ -611,13 +636,6 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { this->save_volume_restore_state_(); } - // Turn on the mute state if the volume is effectively zero, off otherwise - if (volume < 0.001f) { - this->set_mute_state_(true); - } else { - this->set_mute_state_(false); - } - this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 6470fb925c..e98196785c 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 c89b6c588c..277b36a171 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -18,6 +18,9 @@ namespace esphome::speaker { +/// Volumes below this are treated as zero +static constexpr float SILENT_VOLUME_THRESHOLD = 0.001f; + enum State : uint8_t { STATE_STOPPED = 0, STATE_STARTING, @@ -65,13 +68,15 @@ class Speaker { bool is_running() const { return this->state_ == STATE_RUNNING; } bool is_stopped() const { return this->state_ == STATE_STOPPED; } - // Volume control is handled by a configured audio dac component. Individual speaker components can - // override and implement in software if an audio dac isn't available. + // Volume and mute are independent: changing one never alters the other's stored state. Volume control is + // handled by a configured audio dac component. Individual speaker components can override and implement in + // software if an audio dac isn't available. virtual void set_volume(float volume) { this->volume_ = volume; #ifdef USE_AUDIO_DAC if (this->audio_dac_ != nullptr) { this->audio_dac_->set_volume(volume); + this->apply_audio_dac_mute_(); } #endif }; @@ -80,13 +85,7 @@ class Speaker { virtual void set_mute_state(bool mute_state) { this->mute_state_ = mute_state; #ifdef USE_AUDIO_DAC - if (this->audio_dac_) { - if (mute_state) { - this->audio_dac_->set_mute_on(); - } else { - this->audio_dac_->set_mute_off(); - } - } + this->apply_audio_dac_mute_(); #endif } virtual bool get_mute_state() { return this->mute_state_; } @@ -110,10 +109,27 @@ class Speaker { } protected: + /// @brief Whether the output should be silent: muted, or the volume is effectively zero. + /// Volume steps from media players can leave a positive value near float epsilon instead of exactly zero. + bool is_silent_() const { return this->mute_state_ || this->volume_ < SILENT_VOLUME_THRESHOLD; } + +#ifdef USE_AUDIO_DAC + /// @brief Uses the audio dac's mute as the silence mechanism, since a dac's minimum volume is often audible. + void apply_audio_dac_mute_() { + if (this->audio_dac_ == nullptr) + return; + if (this->is_silent_()) { + this->audio_dac_->set_mute_on(); + } else { + this->audio_dac_->set_mute_off(); + } + } +#endif + State state_{STATE_STOPPED}; + 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 a03fa42477..0000000000 --- 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 b6653fe543..4f150e87ff 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 a33a1a1650..6a000b8f21 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -196,10 +196,19 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so vTaskDelay(pdMS_TO_TICKS(timeout_ms)); return 0; } + // Reserve frames before the speaker can play them and report their progress, + // then release the portion it did not accept. + const uint32_t reserved_frames = stream_info.bytes_to_frames(length); + ps.pending_frames.fetch_add(reserved_frames, std::memory_order_relaxed); size_t bytes_written = ps.speaker->play(data, length, pdMS_TO_TICKS(timeout_ms)); - if (bytes_written > 0) { - // Track frames sent to speaker for this source - ps.pending_frames.fetch_add(stream_info.bytes_to_frames(bytes_written), std::memory_order_relaxed); + const uint32_t unused_frames = reserved_frames - stream_info.bytes_to_frames(bytes_written); + if (unused_frames > 0) { + // The main loop may reset pending_frames while play() is blocked. + // Clamp the release so that a concurrent reset cannot cause underflow. + uint32_t pending = ps.pending_frames.load(std::memory_order_relaxed); + while (!ps.pending_frames.compare_exchange_weak(pending, pending - std::min(pending, unused_frames), + std::memory_order_relaxed)) { + } } return bytes_written; } @@ -809,8 +818,11 @@ void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) { } void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit within the configured limits - float bounded_volume = remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < speaker::SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); for (auto &ps : this->pipelines_) { if (ps.is_configured()) { @@ -829,15 +841,6 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { } } - // Turn on the mute state if the volume is effectively zero, off otherwise. - // Pass publish=false to avoid saving twice. - if (volume < 0.001f) { - this->set_mute_state_(true, false); - } else { - this->set_mute_state_(false, false); - } - - // Save after mute mutation so the restored state has the correct is_muted_ value if (publish) { this->save_volume_restore_state_(); } diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index 14428bed62..ae2d2906ed 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -44,16 +44,8 @@ class SPIDelegateHw : public SPIDelegate { #ifdef USE_RP2 this->channel_->transfer(ptr, nullptr, length); #elif defined(USE_ESP8266) - // ESP8266 SPI library requires the pointer to be word aligned, but the data may not be - // so we need to copy the data to a temporary buffer - if (reinterpret_cast(ptr) & 0x3) { - ESP_LOGVV(TAG, "SPI write buffer not word aligned, copying to temporary buffer"); - auto txbuf = std::vector(length); - memcpy(txbuf.data(), ptr, length); - this->channel_->writeBytes(txbuf.data(), length); - } else { - this->channel_->writeBytes(ptr, length); - } + // writeBytes() needs a word aligned pointer; transferBytes() bounces unaligned chunks through a stack buffer + this->channel_->transferBytes(ptr, nullptr, length); #else this->channel_->writeBytes(ptr, length); #endif diff --git a/esphome/components/split_buffer/split_buffer.cpp b/esphome/components/split_buffer/split_buffer.cpp index 526a19c71c..63a0f0d919 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 b615ddce74..6f4ab35e78 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 efa5b0bf15..00144adacd 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 beeec96b98..1de0e1ee11 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,104 +6,6 @@ namespace esphome::sprinkler { -template class SetDividerAction final : public Action { - public: - explicit SetDividerAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(uint32_t, divider) - - void play(const Ts &...x) override { this->sprinkler_->set_divider(this->divider_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetMultiplierAction final : public Action { - public: - explicit SetMultiplierAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(float, multiplier) - - void play(const Ts &...x) override { this->sprinkler_->set_multiplier(this->multiplier_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class QueueValveAction final : public Action { - public: - explicit QueueValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(size_t, valve_number) - TEMPLATABLE_VALUE(uint32_t, valve_run_duration) - - void play(const Ts &...x) override { - this->sprinkler_->queue_valve(this->valve_number_.optional_value(x...), - this->valve_run_duration_.optional_value(x...)); - } - - protected: - Sprinkler *sprinkler_; -}; - -template class ClearQueuedValvesAction final : public Action { - public: - explicit ClearQueuedValvesAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->clear_queued_valves(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetRepeatAction final : public Action { - public: - explicit SetRepeatAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(uint32_t, repeat) - - void play(const Ts &...x) override { this->sprinkler_->set_repeat(this->repeat_.optional_value(x...)); } - - protected: - Sprinkler *sprinkler_; -}; - -template class SetRunDurationAction final : public Action { - public: - explicit SetRunDurationAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - TEMPLATABLE_VALUE(size_t, valve_number) - TEMPLATABLE_VALUE(uint32_t, valve_run_duration) - - void play(const Ts &...x) override { - this->sprinkler_->set_valve_run_duration(this->valve_number_.optional_value(x...), - this->valve_run_duration_.optional_value(x...)); - } - - protected: - Sprinkler *sprinkler_; -}; - -template class StartFromQueueAction final : public Action { - public: - explicit StartFromQueueAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->start_from_queue(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class StartFullCycleAction final : public Action { - public: - explicit StartFullCycleAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->start_full_cycle(); } - - protected: - Sprinkler *sprinkler_; -}; - template class StartSingleValveAction final : public Action { public: explicit StartSingleValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} @@ -132,46 +34,6 @@ template class ShutdownAction final : public Action { Sprinkler *sprinkler_; }; -template class NextValveAction final : public Action { - public: - explicit NextValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->next_valve(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class PreviousValveAction final : public Action { - public: - explicit PreviousValveAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->previous_valve(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class PauseAction final : public Action { - public: - explicit PauseAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->pause(); } - - protected: - Sprinkler *sprinkler_; -}; - -template class ResumeAction final : public Action { - public: - explicit ResumeAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} - - void play(const Ts &...x) override { this->sprinkler_->resume(); } - - protected: - Sprinkler *sprinkler_; -}; - template class ResumeOrStartAction final : public Action { public: explicit ResumeOrStartAction(Sprinkler *a_sprinkler) : sprinkler_(a_sprinkler) {} diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 9fd0d9208b..cdec158126 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -546,11 +546,7 @@ void Sprinkler::set_auto_advance(const bool auto_advance) { if (this->auto_adv_sw_->state == auto_advance) { return; } - if (auto_advance) { - this->auto_adv_sw_->turn_on(); - } else { - this->auto_adv_sw_->turn_off(); - } + this->auto_adv_sw_->control(auto_advance); } void Sprinkler::set_repeat(optional repeat) { @@ -573,11 +569,7 @@ void Sprinkler::set_queue_enable(bool queue_enable) { if (this->queue_enable_sw_->state == queue_enable) { return; } - if (queue_enable) { - this->queue_enable_sw_->turn_on(); - } else { - this->queue_enable_sw_->turn_off(); - } + this->queue_enable_sw_->control(queue_enable); } void Sprinkler::set_reverse(const bool reverse) { @@ -587,11 +579,7 @@ void Sprinkler::set_reverse(const bool reverse) { if (this->reverse_sw_->state == reverse) { return; } - if (reverse) { - this->reverse_sw_->turn_on(); - } else { - this->reverse_sw_->turn_off(); - } + this->reverse_sw_->control(reverse); } void Sprinkler::set_standby(const bool standby) { @@ -601,11 +589,7 @@ void Sprinkler::set_standby(const bool standby) { if (this->standby_sw_->state == standby) { return; } - if (standby) { - this->standby_sw_->turn_on(); - } else { - this->standby_sw_->turn_off(); - } + this->standby_sw_->control(standby); } uint32_t Sprinkler::valve_run_duration(const size_t valve_number) { diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h deleted file mode 100644 index ba978e7770..0000000000 --- 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 681166cd3c..b8a4e08021 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -26,8 +26,6 @@ from esphome.const import ( UNIT_MICROGRAMS_PER_CUBIC_METER, UNIT_MICROMETER, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@martgras"] @@ -39,11 +37,6 @@ SPS30Component = sps30_ns.class_( "SPS30Component", cg.PollingComponent, sensirion_common.SensirionI2CDevice ) -# Actions -StartFanAction = sps30_ns.class_("StartFanAction", automation.Action) -StartMeasurementAction = sps30_ns.class_("StartMeasurementAction", automation.Action) -StopMeasurementAction = sps30_ns.class_("StopMeasurementAction", automation.Action) - CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" CONF_IDLE_INTERVAL = "idle_interval" @@ -128,45 +121,17 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_PM_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_1_0]) - cg.add(var.set_pm_1_0_sensor(sens)) - - if CONF_PM_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PM_2_5]) - cg.add(var.set_pm_2_5_sensor(sens)) - - if CONF_PM_4_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_4_0]) - cg.add(var.set_pm_4_0_sensor(sens)) - - if CONF_PM_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PM_10_0]) - cg.add(var.set_pm_10_0_sensor(sens)) - - if CONF_PMC_0_5 in config: - sens = await sensor.new_sensor(config[CONF_PMC_0_5]) - cg.add(var.set_pmc_0_5_sensor(sens)) - - if CONF_PMC_1_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_1_0]) - cg.add(var.set_pmc_1_0_sensor(sens)) - - if CONF_PMC_2_5 in config: - sens = await sensor.new_sensor(config[CONF_PMC_2_5]) - cg.add(var.set_pmc_2_5_sensor(sens)) - - if CONF_PMC_4_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_4_0]) - cg.add(var.set_pmc_4_0_sensor(sens)) - - if CONF_PMC_10_0 in config: - sens = await sensor.new_sensor(config[CONF_PMC_10_0]) - cg.add(var.set_pmc_10_0_sensor(sens)) - - if CONF_PM_SIZE in config: - sens = await sensor.new_sensor(config[CONF_PM_SIZE]) - cg.add(var.set_pm_size_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_PM_1_0, var.set_pm_1_0_sensor) + await sensors(CONF_PM_2_5, var.set_pm_2_5_sensor) + await sensors(CONF_PM_4_0, var.set_pm_4_0_sensor) + await sensors(CONF_PM_10_0, var.set_pm_10_0_sensor) + await sensors(CONF_PMC_0_5, var.set_pmc_0_5_sensor) + await sensors(CONF_PMC_1_0, var.set_pmc_1_0_sensor) + await sensors(CONF_PMC_2_5, var.set_pmc_2_5_sensor) + await sensors(CONF_PMC_4_0, var.set_pmc_4_0_sensor) + await sensors(CONF_PMC_10_0, var.set_pmc_10_0_sensor) + await sensors(CONF_PM_SIZE, var.set_pm_size_sensor) if CONF_AUTO_CLEANING_INTERVAL in config: cg.add(var.set_auto_cleaning_interval(config[CONF_AUTO_CLEANING_INTERVAL])) @@ -182,30 +147,11 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "sps30.start_fan_autoclean", - StartFanAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sps30.start_measurement", - StartMeasurementAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) -@automation.register_action( - "sps30.stop_measurement", - StopMeasurementAction, - SPS30_ACTION_SCHEMA, - synchronous=True, -) -async def sps30_action_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - return var +for _name, _call in ( + ("sps30.start_fan_autoclean", "start_fan_cleaning()"), + ("sps30.start_measurement", "start_measurement()"), + ("sps30.stop_measurement", "stop_measurement()"), +): + automation.register_apply_action( + _name, SPS30_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/st7701s/st7701s.cpp b/esphome/components/st7701s/st7701s.cpp index 83f7bc9ce5..47b200c2de 100644 --- a/esphome/components/st7701s/st7701s.cpp +++ b/esphome/components/st7701s/st7701s.cpp @@ -84,7 +84,7 @@ void ST7701S::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8 } void ST7701S::draw_pixel_at(int x, int y, Color color) { - if (!this->get_clipping().inside(x, y)) + if (this->is_point_clipped(x, y)) return; // NOLINT switch (this->rotation_) { @@ -107,7 +107,7 @@ void ST7701S::draw_pixel_at(int x, int y, Color color) { this->draw_pixels_at(x, y, 1, 1, (const uint8_t *) &pixel, display::COLOR_ORDER_RGB, display::COLOR_BITNESS_565, true, 0, 0, 0); - App.feed_wdt(); + this->feed_wdt_per_pixel_(); } void ST7701S::write_command_(uint8_t value) { diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index b3a60af8c3..2e07e24522 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -152,8 +152,6 @@ void ST7789V::update() { this->write_display_data(); } -void ST7789V::set_model_str(const char *model_str) { this->model_str_ = model_str; } - void ST7789V::write_display_data() { uint16_t x1 = this->offset_width_; uint16_t x2 = x1 + get_width_internal() - 1; diff --git a/esphome/components/st7789v/st7789v.h b/esphome/components/st7789v/st7789v.h index 1b7ba318a6..4011e607c2 100644 --- a/esphome/components/st7789v/st7789v.h +++ b/esphome/components/st7789v/st7789v.h @@ -110,7 +110,7 @@ class ST7789V final : public display::DisplayBuffer, public spi::SPIDevice { public: - void set_model_str(const char *model_str); + void set_model_str(const char *model_str) { this->model_str_ = model_str; } void set_dc_pin(GPIOPin *dc_pin) { this->dc_pin_ = dc_pin; } void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } void set_backlight_pin(GPIOPin *backlight_pin) { this->backlight_pin_ = backlight_pin; } diff --git a/esphome/components/statsd/__init__.py b/esphome/components/statsd/__init__.py index b5a0586e31..58d4be97fa 100644 --- a/esphome/components/statsd/__init__.py +++ b/esphome/components/statsd/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import binary_sensor, sensor +from esphome.components.const import CONF_HOST import esphome.config_validation as cv from esphome.const import ( CONF_BINARY_SENSORS, @@ -14,7 +15,6 @@ AUTO_LOAD = ["socket"] CODEOWNERS = ["@Links2004"] DEPENDENCIES = ["network"] -CONF_HOST = "host" CONF_PREFIX = "prefix" statsd_component_ns = cg.esphome_ns.namespace("statsd") diff --git a/esphome/components/status/status_binary_sensor.h b/esphome/components/status/status_binary_sensor.h index 28cf4cd083..3c25a9e57d 100644 --- a/esphome/components/status/status_binary_sensor.h +++ b/esphome/components/status/status_binary_sensor.h @@ -7,6 +7,8 @@ namespace esphome::status { class StatusBinarySensor final : public binary_sensor::BinarySensor, public PollingComponent { public: + // User provided, not "= default": `new(p) StatusBinarySensor()` would zero-fill .bss that is already zero. + StatusBinarySensor() {} void update() override; void setup() override; diff --git a/esphome/components/stcc4/__init__.py b/esphome/components/stcc4/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/stcc4/sensor.py b/esphome/components/stcc4/sensor.py new file mode 100644 index 0000000000..212fe6bb48 --- /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 0000000000..efc7a2b8ee --- /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 0000000000..ad25130ec7 --- /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 8e80187662..017fd95cd8 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 06ef3bab37..8a8fbcd896 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 5ef7a699eb..d59f39b9c2 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 33a5c677bd..73ddefe972 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 ea9e05042d..af2af15dd2 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 26435cfa67..2959f36518 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 eae69fb4df..c0a60cacbf 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 18b95113cc..c34a73a36d 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 158fb08baf..0000000000 --- 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 8413c7b493..2cc8ad782d 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -10,7 +10,6 @@ static const char *const TAG = "switch"; Switch::Switch() : state(false) {} void Switch::control(bool target_state) { - ESP_LOGV(TAG, "'%s' Control: %s", this->get_name().c_str(), ONOFF(target_state)); if (target_state) { this->turn_on(); } else { @@ -58,6 +57,7 @@ void Switch::publish_state(bool state) { if (!this->publish_dedup_.next(state)) return; this->state = state != this->inverted_; + this->set_has_state(true); if (restore_mode & RESTORE_MODE_PERSISTENT_MASK) this->rtc_.save(&this->state); diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 29e3ad5359..63c3711b10 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 4eb33abaa1..0dbf3cb42c 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 376676ce85..673464d5eb 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 b3dfe6590a..536bb93c48 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 34f2d4122f..68c56cdff4 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 f6a4537e23..8d409f0b01 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 cd81f08914..9af30400a7 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 070a6eeb96..27031d98b2 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 7694b8f732..942911c7fe 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 2397049000..ee8606db96 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 c7aed2cddd..ef6ee6ec5e 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 c95850aadc..a46d7344b6 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 8f8090b6ee..16db2aa96b 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 03a55393b9..db756960e1 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 bf7fe9a307..e25567c783 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 790d08ffa6..a40829f315 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 4a76f9ac62..f4b2a7dd62 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 0000000000..0695b43d41 --- /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 0000000000..3def5fba12 --- /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 0000000000..08a4677180 --- /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 0000000000..3688176d00 --- /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 0000000000..3a2f6717e9 --- /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 0000000000..99ca31590f --- /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 44dba52ae8..4b055516e7 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 0000000000..81e00ca74b --- /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 0000000000..9fbee8cc29 --- /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 0000000000..191dbf823c --- /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 0000000000..443e2e849e --- /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 0000000000..0931d7ba1f --- /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 0000000000..7b1e3e239e --- /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 0000000000..d057739874 --- /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 0000000000..de0a0c5d75 --- /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 0000000000..3aa74c675e --- /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 0000000000..d042d42e24 --- /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 0000000000..d0f97a5cd9 --- /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 3033cc65e0..6ae1afc883 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 0000000000..796a9fc48d --- /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 0000000000..efc85804e9 --- /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 0000000000..1e1d48e4c2 --- /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 da6edfd62a..898aa94543 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 289641f951..2b1839c529 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/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 57a99f2830..5888ce5e29 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -65,9 +65,6 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl bool get_requires_code_to_arm() const override { return this->requires_code_to_arm_; } bool get_all_sensors_ready() { return this->sensors_ready_; }; void set_restore_mode(TemplateAlarmControlPanelRestoreMode restore_mode) { this->restore_mode_ = restore_mode; } - // Remove before 2026.10.0 - ESPDEPRECATED("bypass_before_arming() is deprecated and will be removed in 2026.10.0", "2026.4.0") - void bypass_before_arming() { this->auto_bypass_sensors_(); } #ifdef USE_BINARY_SENSOR /** Initialize the sensors vector with the specified capacity. diff --git a/esphome/components/template/binary_sensor/__init__.py b/esphome/components/template/binary_sensor/__init__.py index 8f57df91c5..b2d6eca260 100644 --- a/esphome/components/template/binary_sensor/__init__.py +++ b/esphome/components/template/binary_sensor/__init__.py @@ -2,7 +2,13 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor import esphome.config_validation as cv -from esphome.const import CONF_CONDITION, CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import ( + CONF_CONDITION, + CONF_DEVICE_CLASS, + CONF_ID, + CONF_LAMBDA, + CONF_STATE, +) from esphome.cpp_generator import LambdaExpression from .. import template_ns @@ -12,7 +18,11 @@ TemplateBinarySensor = template_ns.class_( ) CONFIG_SCHEMA = ( - binary_sensor.binary_sensor_schema(TemplateBinarySensor) + cv.with_visibility( + binary_sensor.binary_sensor_schema(TemplateBinarySensor), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Exclusive(CONF_LAMBDA, CONF_CONDITION): cv.returning_lambda, @@ -50,20 +60,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "binary_sensor.template.publish", - binary_sensor.BinarySensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(binary_sensor.BinarySensor), cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.bool_), ) -async def binary_sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.h b/esphome/components/template/binary_sensor/template_binary_sensor.h index c78a95e0e3..e1a089b44c 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.h +++ b/esphome/components/template/binary_sensor/template_binary_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateBinarySensor final : public Component, public binary_sensor::BinarySensor { public: + // User provided, not "= default": `new(p) TemplateBinarySensor()` would zero-fill .bss that is already zero. + TemplateBinarySensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void setup() override; diff --git a/esphome/components/template/button/__init__.py b/esphome/components/template/button/__init__.py index e0101dfc8f..9c6fa13c19 100644 --- a/esphome/components/template/button/__init__.py +++ b/esphome/components/template/button/__init__.py @@ -1,10 +1,14 @@ from esphome.components import button +import esphome.config_validation as cv +from esphome.const import CONF_DEVICE_CLASS from .. import template_ns TemplateButton = template_ns.class_("TemplateButton", button.Button) -CONFIG_SCHEMA = button.button_schema(TemplateButton) +CONFIG_SCHEMA = cv.with_visibility( + button.button_schema(TemplateButton), cv.Visibility.UI, CONF_DEVICE_CLASS +) async def to_code(config): diff --git a/esphome/components/template/button/template_button.h b/esphome/components/template/button/template_button.h index f64a85eef0..bd07b2258c 100644 --- a/esphome/components/template/button/template_button.h +++ b/esphome/components/template/button/template_button.h @@ -6,6 +6,9 @@ namespace esphome::template_ { class TemplateButton final : public button::Button { public: + // User provided, not "= default": `new(p) TemplateButton()` would zero-fill .bss that is already zero. + TemplateButton() {} + // Implements the abstract `press_action` but the `on_press` trigger already handles the press. void press_action() override{}; }; diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index c39ea8f80e..5f7627c5c3 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 49a79ace2f..0000000000 --- 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 5448488c34..4cdf213315 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 7cb50df84c..39df5affcf 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_ASSUMED_STATE, CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPEN_ACTION, @@ -19,9 +20,6 @@ from esphome.const import ( CONF_TILT_ACTION, CONF_TILT_LAMBDA, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType from .. import template_ns @@ -38,7 +36,11 @@ CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" CONFIG_SCHEMA = ( - cover.cover_schema(TemplateCover) + cv.with_visibility( + cover.cover_schema(TemplateCover), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, @@ -115,17 +117,8 @@ async def to_code(config): # CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most # one is present and both map to the position field. -_COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( - cover.ApplyField(CONF_STATE, "position", cg.float_), - cover.ApplyField(CONF_POSITION, "position", cg.float_), - cover.ApplyField(CONF_TILT, "tilt", cg.float_), - cover.ApplyField(CONF_CURRENT_OPERATION, "current_operation", cover.CoverOperation), -) - - -@automation.register_action( +automation.register_apply_action( "cover.template.publish", - cover.CoverPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(cover.Cover), @@ -137,22 +130,11 @@ _COVER_PUBLISH_FIELDS: tuple[cover.ApplyField, ...] = ( cv.Optional(CONF_TILT): cv.templatable(cv.zero_to_one_float), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "position = {}", cg.float_), + automation.ApplyField(CONF_POSITION, "position = {}", cg.float_), + automation.ApplyField(CONF_TILT, "tilt = {}", cg.float_), + automation.ApplyField( + CONF_CURRENT_OPERATION, "current_operation = {}", cover.CoverOperation + ), + automation.ApplyCall("publish_state()"), ) -async def cover_template_publish_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - # Mutates Cover fields directly (no CoverCall) since publish is a state - # push, not a control request. - return await cover.build_apply_lambda_action( - config=config, - action_id=action_id, - template_arg=template_arg, - args=args, - fields=_COVER_PUBLISH_FIELDS, - prefix_args=[(cover.Cover.operator("ptr"), "cover")], - statement_fn=lambda field, expr: f"cover->{field} = {expr};", - ) diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index d5e0967e1e..1bf057da5b 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -53,8 +53,6 @@ void TemplateCover::loop() { if (changed) this->publish_state(); } -void TemplateCover::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } -void TemplateCover::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateCover::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateCover::get_open_trigger() { return &this->open_trigger_; } Trigger<> *TemplateCover::get_close_trigger() { return &this->close_trigger_; } @@ -117,10 +115,6 @@ CoverTraits TemplateCover::get_traits() { } Trigger *TemplateCover::get_position_trigger() { return &this->position_trigger_; } Trigger *TemplateCover::get_tilt_trigger() { return &this->tilt_trigger_; } -void TemplateCover::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateCover::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateCover::set_has_position(bool has_position) { this->has_position_ = has_position; } -void TemplateCover::set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } void TemplateCover::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index 20c092cda7..d3096ba86f 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -25,12 +25,12 @@ class TemplateCover final : public cover::Cover, public Component { Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); Trigger *get_tilt_trigger(); - void set_optimistic(bool optimistic); - void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_tilt(bool has_tilt); - void set_has_toggle(bool has_toggle); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateCoverRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; diff --git a/esphome/components/template/event/__init__.py b/esphome/components/template/event/__init__.py index cf9c7f4c3d..bdcbd456d5 100644 --- a/esphome/components/template/event/__init__.py +++ b/esphome/components/template/event/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg from esphome.components import event import esphome.config_validation as cv -from esphome.const import CONF_EVENT_TYPES +from esphome.const import CONF_DEVICE_CLASS, CONF_EVENT_TYPES from .. import template_ns @@ -9,7 +9,9 @@ CODEOWNERS = ["@nohat"] TemplateEvent = template_ns.class_("TemplateEvent", event.Event, cg.Component) -CONFIG_SCHEMA = event.event_schema(TemplateEvent).extend( +CONFIG_SCHEMA = cv.with_visibility( + event.event_schema(TemplateEvent), cv.Visibility.UI, CONF_DEVICE_CLASS +).extend( { cv.Required(CONF_EVENT_TYPES): cv.ensure_list(cv.string_strict), } diff --git a/esphome/components/template/event/template_event.h b/esphome/components/template/event/template_event.h index fe83dc9f34..3d2d9a9efe 100644 --- a/esphome/components/template/event/template_event.h +++ b/esphome/components/template/event/template_event.h @@ -5,6 +5,10 @@ namespace esphome::template_ { -class TemplateEvent final : public Component, public event::Event {}; +class TemplateEvent final : public Component, public event::Event { + public: + // User provided, not "= default": `new(p) TemplateEvent()` would zero-fill .bss that is already zero. + TemplateEvent() {} +}; } // namespace esphome::template_ diff --git a/esphome/components/template/lock/__init__.py b/esphome/components/template/lock/__init__.py index d8bd9d16c6..95a359c67c 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 a979291b78..0000000000 --- a/esphome/components/template/lock/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "template_lock.h" - -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template class TemplateLockPublishAction final : public Action, public Parented { - public: - TEMPLATABLE_VALUE(lock::LockState, state) - - void play(const Ts &...x) override { this->parent_->publish_state(this->state_.value(x...)); } -}; - -} // namespace esphome::template_ diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 6e73623ae9..4a293aab85 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -47,7 +47,6 @@ void TemplateLock::open_latch() { this->prev_trigger_ = &this->open_trigger_; this->open_trigger_.trigger(); } -void TemplateLock::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } float TemplateLock::get_setup_priority() const { return setup_priority::HARDWARE; } void TemplateLock::dump_config() { LOG_LOCK("", "Template Lock", this); diff --git a/esphome/components/template/lock/template_lock.h b/esphome/components/template/lock/template_lock.h index 03e3e86d88..9b0a1ffe98 100644 --- a/esphome/components/template/lock/template_lock.h +++ b/esphome/components/template/lock/template_lock.h @@ -18,7 +18,7 @@ class TemplateLock final : public lock::Lock, public Component { Trigger<> *get_lock_trigger() { return &this->lock_trigger_; } Trigger<> *get_unlock_trigger() { return &this->unlock_trigger_; } Trigger<> *get_open_trigger() { return &this->open_trigger_; } - void set_optimistic(bool optimistic); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void loop() override; float get_setup_priority() const override; diff --git a/esphome/components/template/number/__init__.py b/esphome/components/template/number/__init__.py index 2f4c9cbffe..3b6485fec3 100644 --- a/esphome/components/template/number/__init__.py +++ b/esphome/components/template/number/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, CONF_ID, CONF_INITIAL_VALUE, CONF_LAMBDA, @@ -12,6 +13,7 @@ from esphome.const import ( CONF_RESTORE_VALUE, CONF_SET_ACTION, CONF_STEP, + CONF_UNIT_OF_MEASUREMENT, ) from .. import template_ns @@ -46,7 +48,12 @@ def validate(config): CONFIG_SCHEMA = cv.All( - number.number_schema(TemplateNumber) + cv.with_visibility( + number.number_schema(TemplateNumber), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + ) .extend( { cv.Required(CONF_MAX_VALUE): cv.float_, diff --git a/esphome/components/template/number/template_number.h b/esphome/components/template/number/template_number.h index e51e858ccf..2c4932f7e8 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 5da6d732bd..c6a1b8bd08 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, @@ -23,6 +24,8 @@ void update_lambda(BaseTemplateSelect *sel_comp, const optional &va template class TemplateSelect : public BaseTemplateSelect { public: + // User provided, not "= default": `new(p) TemplateSelect()` would zero-fill .bss that is already zero. + TemplateSelect() {} template void set_lambda(F &&f) { if constexpr (HAS_LAMBDA) { this->f_.set(std::forward(f)); @@ -57,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 0c875bba0f..f831f87d92 100644 --- a/esphome/components/template/sensor/__init__.py +++ b/esphome/components/template/sensor/__init__.py @@ -2,7 +2,16 @@ from esphome import automation import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import ( + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_ID, + CONF_LAMBDA, + CONF_STATE, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, +) from .. import template_ns @@ -11,9 +20,14 @@ TemplateSensor = template_ns.class_( ) CONFIG_SCHEMA = ( - sensor.sensor_schema( - TemplateSensor, - accuracy_decimals=1, + cv.with_visibility( + sensor.sensor_schema(TemplateSensor, accuracy_decimals=1), + cv.Visibility.UI, + CONF_UNIT_OF_MEASUREMENT, + CONF_ACCURACY_DECIMALS, + CONF_DEVICE_CLASS, + CONF_STATE_CLASS, + CONF_FORCE_UPDATE, ) .extend( { @@ -35,20 +49,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "sensor.template.publish", - sensor.SensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(sensor.Sensor), cv.Required(CONF_STATE): cv.templatable(cv.float_), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.float_), ) -async def sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.float_) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/sensor/template_sensor.h b/esphome/components/template/sensor/template_sensor.h index 825a2b4ffa..68e2237267 100644 --- a/esphome/components/template/sensor/template_sensor.h +++ b/esphome/components/template/sensor/template_sensor.h @@ -8,6 +8,8 @@ namespace esphome::template_ { class TemplateSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateSensor()` would zero-fill .bss that is already zero. + TemplateSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; diff --git a/esphome/components/template/switch/__init__.py b/esphome/components/template/switch/__init__.py index ca986365ed..f8e509faf3 100644 --- a/esphome/components/template/switch/__init__.py +++ b/esphome/components/template/switch/__init__.py @@ -4,6 +4,7 @@ from esphome.components import switch import esphome.config_validation as cv from esphome.const import ( CONF_ASSUMED_STATE, + CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC, @@ -31,7 +32,11 @@ def validate(config): CONFIG_SCHEMA = cv.All( - switch.switch_schema(TemplateSwitch) + cv.with_visibility( + switch.switch_schema(TemplateSwitch), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, @@ -67,24 +72,20 @@ async def to_code(config): await automation.build_automation( var.get_turn_on_trigger(), [], config[CONF_TURN_ON_ACTION] ) - cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) - cg.add(var.set_assumed_state(config[CONF_ASSUMED_STATE])) + # optimistic_ and assumed_state_ are false in C++; only emit setters to turn them on. + if config[CONF_OPTIMISTIC]: + cg.add(var.set_optimistic(True)) + if config[CONF_ASSUMED_STATE]: + cg.add(var.set_assumed_state(True)) -@automation.register_action( +automation.register_apply_action( "switch.template.publish", - switch.SwitchPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(switch.Switch), cv.Required(CONF_STATE): cv.templatable(cv.boolean), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.bool_), ) -async def switch_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.bool_) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 05288b2d4e..729db37053 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -29,7 +29,6 @@ void TemplateSwitch::write_state(bool state) { if (this->optimistic_) this->publish_state(state); } -void TemplateSwitch::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } bool TemplateSwitch::assumed_state() { return this->assumed_state_; } float TemplateSwitch::get_setup_priority() const { return setup_priority::HARDWARE - 2.0f; } Trigger<> *TemplateSwitch::get_turn_on_trigger() { return &this->turn_on_trigger_; } @@ -43,17 +42,12 @@ void TemplateSwitch::setup() { if (initial_state.has_value()) { ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value())); // if it has a value, restore_mode is not "DISABLED", therefore act on the switch: - if (initial_state.value()) { - this->turn_on(); - } else { - this->turn_off(); - } + this->control(initial_state.value()); } } void TemplateSwitch::dump_config() { LOG_SWITCH("", "Template Switch", this); ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); } -void TemplateSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } } // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 1714b4f72b..6dc073e4b3 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -17,8 +17,8 @@ class TemplateSwitch final : public switch_::Switch, public Component { template void set_state_lambda(F &&f) { this->f_.set(std::forward(f)); } Trigger<> *get_turn_on_trigger(); Trigger<> *get_turn_off_trigger(); - void set_optimistic(bool optimistic); - void set_assumed_state(bool assumed_state); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } void loop() override; float get_setup_priority() const override; @@ -29,6 +29,7 @@ class TemplateSwitch final : public switch_::Switch, public Component { void write_state(bool state) override; TemplateLambda f_; + // Codegen only emits these setters to turn them on bool optimistic_{false}; bool assumed_state_{false}; Trigger<> turn_on_trigger_; diff --git a/esphome/components/template/text_sensor/__init__.py b/esphome/components/template/text_sensor/__init__.py index ddbdd6dadb..32e616e6f9 100644 --- a/esphome/components/template/text_sensor/__init__.py +++ b/esphome/components/template/text_sensor/__init__.py @@ -1,9 +1,8 @@ from esphome import automation import esphome.codegen as cg from esphome.components import text_sensor -from esphome.components.text_sensor import TextSensorPublishAction import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE +from esphome.const import CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE from .. import template_ns @@ -12,7 +11,11 @@ TemplateTextSensor = template_ns.class_( ) CONFIG_SCHEMA = ( - text_sensor.text_sensor_schema() + cv.with_visibility( + text_sensor.text_sensor_schema(), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.GenerateID(): cv.declare_id(TemplateTextSensor), @@ -34,20 +37,13 @@ async def to_code(config): cg.add(var.set_template(template_)) -@automation.register_action( +automation.register_apply_action( "text_sensor.template.publish", - TextSensorPublishAction, cv.Schema( { cv.Required(CONF_ID): cv.use_id(text_sensor.TextSensor), cv.Required(CONF_STATE): cv.templatable(cv.string_strict), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "publish_state", cg.std_string), ) -async def text_sensor_template_publish_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_STATE], args, cg.std_string) - cg.add(var.set_state(template_)) - return var diff --git a/esphome/components/template/text_sensor/template_text_sensor.h b/esphome/components/template/text_sensor/template_text_sensor.h index 0538a7ec21..8f03f78be4 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.h +++ b/esphome/components/template/text_sensor/template_text_sensor.h @@ -9,6 +9,8 @@ namespace esphome::template_ { class TemplateTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: + // User provided, not "= default": `new(p) TemplateTextSensor()` would zero-fill .bss that is already zero. + TemplateTextSensor() {} template void set_template(F &&f) { this->f_.set(std::forward(f)); } void update() override; diff --git a/esphome/components/template/time/__init__.py b/esphome/components/template/time/__init__.py new file mode 100644 index 0000000000..0332198e78 --- /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 0000000000..4b523def25 --- /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 0000000000..ec381115d6 --- /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 a2d0c19880..a7fd6f7632 100644 --- a/esphome/components/template/valve/__init__.py +++ b/esphome/components/template/valve/__init__.py @@ -6,7 +6,7 @@ from esphome.const import ( CONF_ASSUMED_STATE, CONF_CLOSE_ACTION, CONF_CURRENT_OPERATION, - CONF_ID, + CONF_DEVICE_CLASS, CONF_LAMBDA, CONF_OPEN_ACTION, CONF_OPTIMISTIC, @@ -21,10 +21,6 @@ from .. import template_ns TemplateValve = template_ns.class_("TemplateValve", valve.Valve, cg.Component) -TemplateValvePublishAction = template_ns.class_( - "TemplateValvePublishAction", automation.Action, cg.Parented.template(TemplateValve) -) - TemplateValveRestoreMode = template_ns.enum("TemplateValveRestoreMode") RESTORE_MODES = { "NO_RESTORE": TemplateValveRestoreMode.VALVE_NO_RESTORE, @@ -36,7 +32,11 @@ CONF_HAS_POSITION = "has_position" CONF_TOGGLE_ACTION = "toggle_action" CONFIG_SCHEMA = ( - valve.valve_schema(TemplateValve) + cv.with_visibility( + valve.valve_schema(TemplateValve), + cv.Visibility.UI, + CONF_DEVICE_CLASS, + ) .extend( { cv.Optional(CONF_LAMBDA): cv.returning_lambda, @@ -99,9 +99,10 @@ async def to_code(config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) -@automation.register_action( +# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most +# one is present and both write the position. +automation.register_apply_action( "valve.template.publish", - TemplateValvePublishAction, cv.Schema( { cv.GenerateID(): cv.use_id(TemplateValve), @@ -112,20 +113,10 @@ async def to_code(config): ), } ), - synchronous=True, + automation.ApplyField(CONF_STATE, "position = {}", cg.float_), + automation.ApplyField(CONF_POSITION, "position = {}", cg.float_), + automation.ApplyField( + CONF_CURRENT_OPERATION, "current_operation = {}", valve.ValveOperation + ), + automation.ApplyCall("publish_state()"), ) -async def valve_template_publish_to_code(config, action_id, template_arg, args): - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - if state_config := config.get(CONF_STATE): - template_ = await cg.templatable(state_config, args, cg.float_) - cg.add(var.set_position(template_)) - if (position_config := config.get(CONF_POSITION)) is not None: - template_ = await cg.templatable(position_config, args, cg.float_) - cg.add(var.set_position(template_)) - if current_operation_config := config.get(CONF_CURRENT_OPERATION): - template_ = await cg.templatable( - current_operation_config, args, valve.ValveOperation - ) - cg.add(var.set_current_operation(template_)) - return var diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h deleted file mode 100644 index ec9d784ab6..0000000000 --- a/esphome/components/template/valve/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "template_valve.h" - -#include "esphome/core/automation.h" - -namespace esphome::template_ { - -template class TemplateValvePublishAction final : public Action, public Parented { - TEMPLATABLE_VALUE(float, position) - TEMPLATABLE_VALUE(valve::ValveOperation, current_operation) - - void play(const Ts &...x) override { - if (this->position_.has_value()) - this->parent_->position = this->position_.value(x...); - if (this->current_operation_.has_value()) - this->parent_->current_operation = this->current_operation_.value(x...); - this->parent_->publish_state(); - } -}; - -} // namespace esphome::template_ diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index 3ebeec1285..5090687639 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -47,8 +47,6 @@ void TemplateValve::loop() { this->publish_state(); } -void TemplateValve::set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } -void TemplateValve::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } float TemplateValve::get_setup_priority() const { return setup_priority::HARDWARE; } Trigger<> *TemplateValve::get_open_trigger() { return &this->open_trigger_; } @@ -111,10 +109,6 @@ ValveTraits TemplateValve::get_traits() { Trigger *TemplateValve::get_position_trigger() { return &this->position_trigger_; } -void TemplateValve::set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } -void TemplateValve::set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } -void TemplateValve::set_has_position(bool has_position) { this->has_position_ = has_position; } - void TemplateValve::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { this->prev_command_trigger_->stop_action(); diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index 76c4630aa0..504fdb2fba 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -23,11 +23,11 @@ class TemplateValve final : public valve::Valve, public Component { Trigger<> *get_stop_trigger(); Trigger<> *get_toggle_trigger(); Trigger *get_position_trigger(); - void set_optimistic(bool optimistic); - void set_assumed_state(bool assumed_state); - void set_has_stop(bool has_stop); - void set_has_position(bool has_position); - void set_has_toggle(bool has_toggle); + void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } + void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } + void set_has_stop(bool has_stop) { this->has_stop_ = has_stop; } + void set_has_position(bool has_position) { this->has_position_ = has_position; } + void set_has_toggle(bool has_toggle) { this->has_toggle_ = has_toggle; } void set_restore_mode(TemplateValveRestoreMode restore_mode) { restore_mode_ = restore_mode; } void setup() override; diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 9d6a3523d2..7718688d68 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 e010e2c292..2f82f19ff5 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 916d86340d..bd6fb3cd23 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 5c8d71696f..08870db64a 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 628b9b84a0..0000000000 --- a/esphome/components/text_sensor/automation.h +++ /dev/null @@ -1,48 +0,0 @@ -#pragma once - -#include - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/text_sensor/text_sensor.h" - -namespace esphome::text_sensor { - -class TextSensorStateTrigger final : public Trigger { - public: - explicit TextSensorStateTrigger(TextSensor *parent) { - parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -class TextSensorStateRawTrigger final : public Trigger { - public: - explicit TextSensorStateRawTrigger(TextSensor *parent) { - parent->add_on_raw_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -template class TextSensorStateCondition final : public Condition { - public: - explicit TextSensorStateCondition(TextSensor *parent) : parent_(parent) {} - - TEMPLATABLE_VALUE(std::string, state) - - bool check(const Ts &...x) override { return this->parent_->state == this->state_.value(x...); } - - protected: - TextSensor *parent_; -}; - -template class TextSensorPublishAction final : public Action { - public: - TextSensorPublishAction(TextSensor *sensor) : sensor_(sensor) {} - TEMPLATABLE_VALUE(std::string, state) - - void play(const Ts &...x) override { this->sensor_->publish_state(this->state_.value(x...)); } - - protected: - TextSensor *sensor_; -}; - -} // namespace esphome::text_sensor diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 0e7364bf98..5041ebc4e0 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -29,7 +29,8 @@ class TextSensor : public EntityBase { public: std::string state; - TextSensor() = default; + // User provided, not "= default": `new(p) TextSensor()` would zero-fill .bss that is already zero. + TextSensor() {} ~TextSensor() = default; /// Getter-syntax for .state. diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py new file mode 100644 index 0000000000..6ba821b1ab --- /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 0000000000..753ac7de33 --- /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 0000000000..b973f66864 --- /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 0000000000..a52f479eea --- /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 0000000000..546be2742d --- /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 0000000000..7caaebe6b3 --- /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 0000000000..3fb9ed20a6 --- /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 0000000000..6844f3a726 --- /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 0000000000..1466ba8947 --- /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 0000000000..0cc866ef1d --- /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 c660e5fcef..ed42312a26 100644 --- a/esphome/components/thermopro_ble/sensor.py +++ b/esphome/components/thermopro_ble/sensor.py @@ -82,18 +82,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity(sens)) - if battery_level_config := config.get(CONF_BATTERY_LEVEL): - sens = await sensor.new_sensor(battery_level_config) - cg.add(var.set_battery_level(sens)) - if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): - sens = await sensor.new_sensor(signal_strength_config) - cg.add(var.set_signal_strength(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature) + await sensors(CONF_HUMIDITY, var.set_humidity) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength) diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index e830d359c6..f64673e13f 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -1304,8 +1304,6 @@ void ThermostatClimate::set_default_preset(const char *custom_preset) { this->default_custom_preset_ = nullptr; } -void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } - void ThermostatClimate::set_timer_duration_in_sec_(ThermostatClimateTimerIndex timer_index, uint32_t time) { uint32_t new_duration_ms = 1000 * (time < this->min_timer_duration_ ? this->min_timer_duration_ : time); diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index 4dc2a74d8e..b7d46eae22 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -92,7 +92,7 @@ class ThermostatClimate final : public climate::Climate, public Component { void loop() override; void set_default_preset(const char *custom_preset); - void set_default_preset(climate::ClimatePreset preset); + void set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } void set_on_boot_restore_from(thermostat::OnBootRestoreFrom on_boot_restore_from) { this->on_boot_restore_from_ = on_boot_restore_from; } diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index ecc448a96a..cedb9b3c9b 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 c449309c9f..f98adb67b5 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 53c4ab0073..1b819088ca 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,4 +1,4 @@ -from esphome import final_validate as fv +from esphome import automation, final_validate as fv, pins import esphome.codegen as cg from esphome.components import esp32 from esphome.components.esp32 import ( @@ -17,12 +17,15 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] +CONF_ON_MOUNT = "on_mount" +CONF_ON_UNMOUNT = "on_unmount" CONF_USB_LANG_ID = "usb_lang_id" CONF_USB_MANUFACTURER_STR = "usb_manufacturer_str" CONF_USB_PRODUCT_ID = "usb_product_id" CONF_USB_PRODUCT_STR = "usb_product_str" CONF_USB_SERIAL_STR = "usb_serial_str" CONF_USB_VENDOR_ID = "usb_vendor_id" +CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # Components that provide a USB device class (CDC, HID, MSC, ...) on top of # tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in @@ -34,6 +37,19 @@ _USB_CLASS_COMPONENTS = ("usb_cdc_acm",) tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_MOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnTrueForwarder, + ), + automation.CallbackAutomation( + CONF_ON_UNMOUNT, + "add_on_mount_state_callback", + forwarder=automation.TriggerOnFalseForwarder, + ), +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -44,6 +60,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_USB_MANUFACTURER_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_PRODUCT_STR, default="ESPHome"): cv.string, cv.Optional(CONF_USB_SERIAL_STR, default=""): cv.string, + # esp_tinyusb monitors VBUS on the S31 through a GPIO interrupt and needs + # the GPIO ISR service installed first, which would collide with the esp32 + # platform's own lazy install and disable other interrupts. The other + # variants watch the pin in the OTG hardware. + cv.Optional(CONF_VBUS_MONITOR_PIN): cv.All( + pins.internal_gpio_input_pin_number, + esp32.only_on_variant( + unsupported=[VARIANT_ESP32S31], msg_prefix=CONF_VBUS_MONITOR_PIN + ), + ), + cv.Optional(CONF_ON_MOUNT): automation.validate_automation({}), + cv.Optional(CONF_ON_UNMOUNT): automation.validate_automation({}), } ).extend(cv.COMPONENT_SCHEMA), esp32.only_on_variant( @@ -93,9 +121,20 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_usb_desc_product(config[CONF_USB_PRODUCT_STR])) if config[CONF_USB_SERIAL_STR]: cg.add(var.set_usb_desc_serial(config[CONF_USB_SERIAL_STR])) + if (vbus_pin := config.get(CONF_VBUS_MONITOR_PIN)) is not None: + cg.add(var.set_vbus_monitor_pin(vbus_pin)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) add_idf_component(name="espressif/esp_tinyusb", ref="2.2.1") add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_ESPRESSIF_VID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_USE_DEFAULT_PID", False) add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) + + +automation.register_apply_condition( + "tinyusb.is_mounted", + cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), + "is_mounted()", +) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index c8c36f0ffb..3fab9de008 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -9,6 +9,14 @@ namespace esphome::tinyusb { static const char *const TAG = "tinyusb"; +// Runs on the TinyUSB task: only wake the main loop, which reads the state and runs +// the automations. +static void tinyusb_event_cb(tinyusb_event_t *event, void *arg) { + if (event->id == TINYUSB_EVENT_ATTACHED || event->id == TINYUSB_EVENT_DETACHED) { + static_cast(arg)->enable_loop_soon_any_context(); + } +} + void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { @@ -21,6 +29,12 @@ void TinyUSB::setup() { this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; + // Without VBUS monitoring the OTG core only sees a cable pull as the bus going idle + // (a suspend), so TinyUSB never reports a detach and stays "mounted". + if (this->vbus_monitor_pin_ >= 0) { + this->tusb_cfg_.phy.self_powered = true; + this->tusb_cfg_.phy.vbus_monitor_io = this->vbus_monitor_pin_; + } this->tusb_cfg_.descriptor = { .device = &this->usb_descriptor_, .string = this->string_descriptor_, @@ -42,11 +56,26 @@ void TinyUSB::setup() { } #endif + this->tusb_cfg_.event_cb = tinyusb_event_cb; + this->tusb_cfg_.event_arg = this; esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_); if (result != ESP_OK) { ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result)); this->mark_failed(); + return; } + // loop() only reports mount changes; the mount hooks wake it when one happens. + this->disable_loop(); +} + +void TinyUSB::loop() { + const bool mounted = tud_mounted(); + if (mounted != this->last_reported_mounted_) { + this->last_reported_mounted_ = mounted; + ESP_LOGD(TAG, "USB host %s", mounted ? LOG_STR_LITERAL("mounted") : LOG_STR_LITERAL("unmounted")); + this->mount_state_callback_.call(mounted); + } + this->disable_loop(); } void TinyUSB::dump_config() { @@ -56,9 +85,12 @@ void TinyUSB::dump_config() { " Vendor ID: 0x%04X\n" " Manufacturer: '%s'\n" " Product: '%s'\n" - " Serial: '%s'\n", + " Serial: '%s'", this->usb_descriptor_.idProduct, this->usb_descriptor_.idVendor, this->string_descriptor_[MANUFACTURER], this->string_descriptor_[PRODUCT], this->string_descriptor_[SERIAL_NUMBER]); + if (this->vbus_monitor_pin_ >= 0) { + ESP_LOGCONFIG(TAG, " VBUS Monitor Pin: GPIO%d", this->vbus_monitor_pin_); + } } } // namespace esphome::tinyusb diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index e85fea9d21..030356bc03 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -2,7 +2,9 @@ #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) #include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include #include "tinyusb.h" #include "tusb.h" @@ -23,9 +25,17 @@ static const char *const DEFAULT_USB_STR = "ESPHome"; class TinyUSB final : public Component { public: void setup() override; + void loop() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } + /// True while a USB host has enumerated and configured the device. + bool is_mounted() const { return tud_mounted(); } + /// Called with the new mount state whenever a host mounts or unmounts the device. + template void add_on_mount_state_callback(F &&callback) { + this->mount_state_callback_.add(std::forward(callback)); + } + void set_usb_desc_product_id(uint16_t product_id) { this->usb_descriptor_.idProduct = product_id; } void set_usb_desc_vendor_id(uint16_t vendor_id) { this->usb_descriptor_.idVendor = vendor_id; } void set_usb_desc_lang_id(uint16_t lang_id) { @@ -37,6 +47,8 @@ class TinyUSB final : public Component { } void set_usb_desc_product(const char *usb_desc_product) { this->string_descriptor_[PRODUCT] = usb_desc_product; } void set_usb_desc_serial(const char *usb_desc_serial) { this->string_descriptor_[SERIAL_NUMBER] = usb_desc_serial; } + /// Self-powered device: watch VBUS on this GPIO so a cable pull becomes a detach. + void set_vbus_monitor_pin(int pin) { this->vbus_monitor_pin_ = static_cast(pin); } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english @@ -50,6 +62,11 @@ class TinyUSB final : public Component { nullptr, // 5: Terminator }; + LazyCallbackManager mount_state_callback_; + // Edge-detection baseline for loop(); is_mounted() reads the live state instead. + bool last_reported_mounted_{false}; + int8_t vbus_monitor_pin_{-1}; + tinyusb_config_t tusb_cfg_{}; tusb_desc_device_t usb_descriptor_{ .bLength = sizeof(tusb_desc_device_t), diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index c0cc6f1d2c..dfe475f7f2 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 2021f90266..caf526046b 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 3cbda4a57a..ab510db0c5 100644 --- a/esphome/components/tmp1075/sensor.py +++ b/esphome/components/tmp1075/sensor.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import CONF_CONVERSION_RATE import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_TEMPERATURE, @@ -41,7 +42,6 @@ CONF_LIMIT_LOW = "limit_low" CONF_LIMIT_HIGH = "limit_high" CONF_FAULT_COUNT = "fault_count" CONF_POLARITY = "polarity" -CONF_CONVERSION_RATE = "conversion_rate" CONF_FUNCTION = "function" CONFIG_SCHEMA = ( diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index 3b1e7352f9..e5f8544f2f 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import climate_ir +from esphome.components import climate_ir, remote_base import esphome.config_validation as cv from esphome.const import CONF_MODEL from esphome.types import ConfigType @@ -26,5 +26,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).exten async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("toshiba_ac") # used from C++ var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) diff --git a/esphome/components/touchscreen/touchscreen.cpp b/esphome/components/touchscreen/touchscreen.cpp index f4ef66ef3e..cb36733e2f 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/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 963114b230..4e4d403488 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -145,8 +145,6 @@ float TSL2561Sensor::get_integration_time_ms_() { void TSL2561Sensor::set_integration_time(TSL2561IntegrationTime integration_time) { this->integration_time_ = integration_time; } -void TSL2561Sensor::set_gain(TSL2561Gain gain) { this->gain_ = gain; } -void TSL2561Sensor::set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } bool TSL2561Sensor::tsl2561_write_byte(uint8_t a_register, uint8_t value) { return this->write_byte(a_register | TSL2561_COMMAND_BIT, value); diff --git a/esphome/components/tsl2561/tsl2561.h b/esphome/components/tsl2561/tsl2561.h index 8997d19f53..0800b87c46 100644 --- a/esphome/components/tsl2561/tsl2561.h +++ b/esphome/components/tsl2561/tsl2561.h @@ -51,7 +51,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param gain The new gain. */ - void set_gain(TSL2561Gain gain); + void set_gain(TSL2561Gain gain) { this->gain_ = gain; } /** The "CS" package of this sensor has a slightly different formula for * converting the raw values. Use this setting to indicate that this is a CS @@ -59,7 +59,7 @@ class TSL2561Sensor final : public sensor::Sensor, public PollingComponent, publ * * @param package_cs Is this a CS package. */ - void set_is_cs_package(bool package_cs); + void set_is_cs_package(bool package_cs) { this->package_cs_ = package_cs; } // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/tsl2591/sensor.py b/esphome/components/tsl2591/sensor.py index fa202b0cea..ee6ebaeb75 100644 --- a/esphome/components/tsl2591/sensor.py +++ b/esphome/components/tsl2591/sensor.py @@ -140,30 +140,12 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_FULL_SPECTRUM in config: - conf = config[CONF_FULL_SPECTRUM] - sens = await sensor.new_sensor(conf) - cg.add(var.set_full_spectrum_sensor(sens)) - - if CONF_INFRARED in config: - conf = config[CONF_INFRARED] - sens = await sensor.new_sensor(conf) - cg.add(var.set_infrared_sensor(sens)) - - if CONF_VISIBLE in config: - conf = config[CONF_VISIBLE] - sens = await sensor.new_sensor(conf) - cg.add(var.set_visible_sensor(sens)) - - if CONF_CALCULATED_LUX in config: - conf = config[CONF_CALCULATED_LUX] - sens = await sensor.new_sensor(conf) - cg.add(var.set_calculated_lux_sensor(sens)) - - if CONF_ACTUAL_GAIN in config: - conf = config[CONF_ACTUAL_GAIN] - sens = await sensor.new_sensor(conf) - cg.add(var.set_actual_gain_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FULL_SPECTRUM, var.set_full_spectrum_sensor) + await sensors(CONF_INFRARED, var.set_infrared_sensor) + await sensors(CONF_VISIBLE, var.set_visible_sensor) + await sensors(CONF_CALCULATED_LUX, var.set_calculated_lux_sensor) + await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) diff --git a/esphome/components/tsl2591/tsl2591.cpp b/esphome/components/tsl2591/tsl2591.cpp index 2a5d6a4ee4..d147aae88a 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -200,8 +200,6 @@ void TSL2591Component::set_infrared_sensor(sensor::Sensor *infrared_sensor) { this->infrared_sensor_ = infrared_sensor; } -void TSL2591Component::set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } - void TSL2591Component::set_full_spectrum_sensor(sensor::Sensor *full_spectrum_sensor) { this->full_spectrum_sensor_ = full_spectrum_sensor; } @@ -218,8 +216,6 @@ void TSL2591Component::set_integration_time(TSL2591IntegrationTime integration_t this->integration_time_ = integration_time; } -void TSL2591Component::set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } - void TSL2591Component::set_device_and_glass_attenuation_factors(float device_factor, float glass_attenuation_factor) { this->device_factor_ = device_factor; this->glass_attenuation_factor_ = glass_attenuation_factor; @@ -242,10 +238,6 @@ void TSL2591Component::set_integration_time_and_gain(TSL2591IntegrationTime inte } } -void TSL2591Component::set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } - -void TSL2591Component::set_name(const char *name) { this->name_ = name; } - bool TSL2591Component::is_adc_valid() { uint8_t status; if (!this->read_byte(TSL2591_COMMAND_BIT | TSL2591_REGISTER_STATUS, &status)) { diff --git a/esphome/components/tsl2591/tsl2591.h b/esphome/components/tsl2591/tsl2591.h index 3fde340412..c65fc5f6e5 100644 --- a/esphome/components/tsl2591/tsl2591.h +++ b/esphome/components/tsl2591/tsl2591.h @@ -111,13 +111,13 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { * * @param enable Enable or disable power save mode. */ - void set_power_save_mode(bool enable); + void set_power_save_mode(bool enable) { this->power_save_mode_enabled_ = enable; } /** Sets the name for this instance of the device. * * @param name The user-friendly name. */ - void set_name(const char *name); + void set_name(const char *name) { this->name_ = name; } /** Sets the device and glass attenuation factors. * @@ -235,13 +235,13 @@ class TSL2591Component final : public PollingComponent, public i2c::I2CDevice { /** Used by ESPHome framework. */ void set_infrared_sensor(sensor::Sensor *infrared_sensor); /** Used by ESPHome framework. */ - void set_visible_sensor(sensor::Sensor *visible_sensor); + void set_visible_sensor(sensor::Sensor *visible_sensor) { this->visible_sensor_ = visible_sensor; } /** Used by ESPHome framework. */ void set_calculated_lux_sensor(sensor::Sensor *calculated_lux_sensor); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ void set_integration_time(TSL2591IntegrationTime integration_time); /** Used by ESPHome framework. Does NOT actually set the value on the device. */ - void set_gain(TSL2591ComponentGain gain); + void set_gain(TSL2591ComponentGain gain) { this->component_gain_ = gain; } /** Used by ESPHome framework. */ void setup() override; /** Used by ESPHome framework. */ diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index 9f3f3c13cc..32cbca9a39 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 3a24b8dc0b..77418e5284 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 78633bcf6a..25fbfb7599 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 eda5b72ea8..d1c6773363 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 3e52531791..8f143b7977 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 2f8b4dbd11..0f6d7f6577 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 469885b6b6..0098e752b2 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 bbeb86bcdb..5d90fc1c8a 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)); @@ -160,6 +188,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { this->mark_failed(); return; } + this->last_good_framing_ = this->framing_(); int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1; @@ -189,18 +218,9 @@ void IDFUARTComponent::load_settings(bool dump_config) { setup_pin_if_needed(this->tx_pin_); } - uint32_t invert = 0; - if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { - invert |= UART_SIGNAL_TXD_INV; - } - if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { - invert |= UART_SIGNAL_RXD_INV; - } - if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { - invert |= UART_SIGNAL_RTS_INV; - } - - err = uart_set_line_inverse(this->uart_num_, invert); + // Must precede uart_set_pin() so an inverted TX line never shows the wrong idle + // level; apply_line_settings_() repeats it later for the reset registers. + err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); this->mark_failed(); @@ -214,25 +234,7 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). - auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; - err = uart_set_mode(this->uart_num_, mode); - if (err != ESP_OK) { - ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + if (this->apply_line_settings_() != ESP_OK) { this->mark_failed(); return; } @@ -250,6 +252,99 @@ void IDFUARTComponent::load_settings(bool dump_config) { } } +uint32_t IDFUARTComponent::line_inversion_mask_() { + uint32_t invert = 0; + if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) { + invert |= UART_SIGNAL_TXD_INV; + } + if (this->rx_pin_ != nullptr && this->rx_pin_->is_inverted()) { + invert |= UART_SIGNAL_RXD_INV; + } + if (this->flow_control_pin_ != nullptr && this->flow_control_pin_->is_inverted()) { + invert |= UART_SIGNAL_RTS_INV; + } + return invert; +} + +esp_err_t IDFUARTComponent::apply_line_settings_() { + // uart_param_config() resets these; call after every use of it. + esp_err_t err = uart_set_line_inverse(this->uart_num_, this->line_inversion_mask_()); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_line_inverse failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_full_threshold(this->uart_num_, this->rx_full_threshold_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_full_threshold failed: %s", esp_err_to_name(err)); + return err; + } + + err = uart_set_rx_timeout(this->uart_num_, this->rx_timeout_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_rx_timeout failed: %s", esp_err_to_name(err)); + return err; + } + + // Per ESP-IDF docs, uart_set_mode() must be called only after uart_driver_install(). + auto mode = this->flow_control_pin_ != nullptr ? UART_MODE_RS485_HALF_DUPLEX : UART_MODE_UART; + err = uart_set_mode(this->uart_num_, mode); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_set_mode failed: %s", esp_err_to_name(err)); + return err; + } + + return ESP_OK; +} + +void IDFUARTComponent::set_framing_(const Framing &framing) { + this->baud_rate_ = framing.baud_rate; + this->data_bits_ = framing.data_bits; + this->stop_bits_ = framing.stop_bits; + this->parity_ = framing.parity; + this->rx_full_threshold_ = framing.rx_full_threshold; +} + +esp_err_t IDFUARTComponent::apply_settings_live() { + if (this->is_failed()) { + return ESP_ERR_INVALID_STATE; + } + // No driver yet: nothing to reconfigure in place. + if (!uart_is_driver_installed(this->uart_num_)) { + this->load_settings(false); + return this->is_failed() ? ESP_FAIL : ESP_OK; + } + // Keeps the driver ring buffers; flushes both hardware FIFOs (in-flight bytes lost). + uart_config_t uart_config = this->get_config_(); + esp_err_t err = uart_param_config(this->uart_num_, &uart_config); + if (err != ESP_OK) { + // Failure leaves the registers reset; put back the last accepted framing so the + // getters still describe the hardware. + if (this->last_good_framing_.baud_rate == 0) { + ESP_LOGE(TAG, "uart_param_config (live) failed: %s; no previous framing to restore", esp_err_to_name(err)); + this->mark_failed(); + return err; + } + ESP_LOGW(TAG, "uart_param_config (live) failed: %s; restoring %" PRIu32 " baud", esp_err_to_name(err), + this->last_good_framing_.baud_rate); + this->set_framing_(this->last_good_framing_); + uart_config = this->get_config_(); + esp_err_t restore_err = uart_param_config(this->uart_num_, &uart_config); + if (restore_err != ESP_OK) { + ESP_LOGE(TAG, "UART left unconfigured after failed live reconfigure: %s", esp_err_to_name(restore_err)); + this->mark_failed(); + return err; + } + // Previous framing is live again; report the refusal (line-setting errors log). + this->apply_line_settings_(); + return err; + } + this->last_good_framing_ = this->framing_(); + // The new framing is live; a line-setting failure here only logs. + this->apply_line_settings_(); + return ESP_OK; +} + void IDFUARTComponent::dump_config() { ESP_LOGCONFIG(TAG, "UART Bus %u:", this->uart_num_); LOG_PIN(" TX Pin: ", this->tx_pin_); @@ -262,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_); } @@ -269,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(); } @@ -300,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 a761d80f04..7c93b74fbd 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -18,6 +18,8 @@ namespace esphome::uart { /// peek byte state (has_peek_/peek_byte_) is not synchronized. class IDFUARTComponent final : public UARTComponent, public Component { public: + // User provided, not "= default": `new(p) IDFUARTComponent()` would zero-fill .bss that is already zero. + IDFUARTComponent() {} void setup() override; void dump_config() override; float get_setup_priority() const override { return setup_priority::BUS; } @@ -31,12 +33,25 @@ 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. + void flush_input() { + this->has_peek_ = false; + uart_flush_input(this->uart_num_); + } + /** * Load the UART with the current settings. * @param dump_config (Optional, default `true`): True for displaying new settings or @@ -52,16 +67,54 @@ class IDFUARTComponent final : public UARTComponent, public Component { void load_settings(bool dump_config) override; using UARTComponent::load_settings; // also bring in the no-arg overload for convenience + /** + * Apply the current framing (baud rate, parity, data/stop bits) to the installed + * driver in place, without the delete/reinstall of load_settings(). Tasks blocked in + * the driver survive and the ring buffers are kept, but both hardware FIFOs are + * flushed: a frame in flight reaches the peer truncated and bytes not yet out of the + * RX FIFO are dropped. No lock is taken: quiesce writers first if that matters. + * rx_full_threshold is not rescaled (call set_rx_full_threshold_ms() first if it + * should follow the baud rate); a rollback restores the value from the last accepted + * configuration, undoing a standalone set_rx_full_threshold() made since. Without an + * installed driver this is a full load_settings(false) instead. + * + * @return ESP_OK once the new framing is live (a line-setting error after that only + * logs). On rejection (unreachable baud rate) the previous framing is restored and + * the driver's error returned; if the restore fails too the component is marked + * failed. ESP_ERR_INVALID_STATE if already failed; ESP_FAIL if the fallback + * load_settings() fails. + */ + esp_err_t apply_settings_live(); + void on_shutdown() override; protected: void check_logger_conflict() override; + uint32_t line_inversion_mask_(); + // Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode. + esp_err_t apply_line_settings_(); uart_port_t uart_num_{UART_NUM_MAX}; uart_config_t get_config_(); + struct Framing { + uint32_t baud_rate; + uint8_t data_bits; + uint8_t stop_bits; + UARTParityOptions parity; + size_t rx_full_threshold; // sized for the baud rate, so rolled back with it + }; + Framing framing_() const { + return {this->baud_rate_, this->data_bits_, this->stop_bits_, this->parity_, this->rx_full_threshold_}; + } + void set_framing_(const Framing &framing); + // Last framing the driver accepted; baud_rate 0 means none yet. + Framing last_good_framing_{}; + bool has_peek_{false}; - uint8_t peek_byte_; - uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + uint8_t peek_byte_{0}; + uint8_t clock_source_{UART_SCLK_DEFAULT}; ///< uart_sclk_t stored in a byte; the IDF values are all small. + uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + 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 4eacd980db..293cb9e786 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 new file mode 100644 index 0000000000..f97a7b2cb3 --- /dev/null +++ b/esphome/components/uart_mux/__init__.py @@ -0,0 +1,82 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32, uart +from esphome.components.cdc_acm_uart.bridge import CDCACMUARTBridge +from esphome.components.esp32 import VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3 +import esphome.config_validation as cv +from esphome.const import CONF_ID +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +DOMAIN = "uart_mux" +DEPENDENCIES = ["bridge", "uart"] +MULTI_CONF = True + +CONF_BRIDGE_ID = "bridge_id" +CONF_INITIAL_ROUTE = "initial_route" +ROUTE_BRIDGE = "bridge" +ROUTE_LOCAL = "local" + +uart_mux_ns = cg.esphome_ns.namespace("uart_mux") +UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(UARTMux), + cv.Required(CONF_BRIDGE_ID): cv.use_id(CDCACMUARTBridge), + cv.Optional(CONF_INITIAL_ROUTE, default=ROUTE_BRIDGE): cv.one_of( + ROUTE_BRIDGE, ROUTE_LOCAL, lower=True + ), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +def _final_validate(config: ConfigType) -> ConfigType: + # Two muxes on one bridge would each believe they own the bus. + owned = fv.full_config.get().data.setdefault(DOMAIN, set()) + bridge_id = str(config[CONF_BRIDGE_ID]) + if bridge_id in owned: + raise cv.Invalid( + f"The bridge '{bridge_id}' is already routed by another 'uart_mux'; " + "each bridge supports one mux.", + [CONF_BRIDGE_ID], + ) + owned.add(bridge_id) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + bridge = await cg.get_variable(config[CONF_BRIDGE_ID]) + var = cg.new_Pvariable(config[CONF_ID], bridge) + await cg.register_component(var, config) + if config[CONF_INITIAL_ROUTE] == ROUTE_LOCAL: + cg.add(var.set_start_local(True)) + + +UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.Required(CONF_ID): cv.use_id(UARTMux)} +) + + +automation.register_apply_action( + "uart_mux.select_local", + UART_MUX_ACTION_SCHEMA, + automation.ApplyCall("select_local()"), +) +automation.register_apply_action( + "uart_mux.select_bridge", + UART_MUX_ACTION_SCHEMA, + automation.ApplyCall("select_bridge()"), +) +automation.register_apply_condition( + "uart_mux.is_local", UART_MUX_ACTION_SCHEMA, "is_local()" +) diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp new file mode 100644 index 0000000000..953e81d533 --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -0,0 +1,115 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "uart_mux.h" +#include "esphome/core/log.h" + +namespace esphome::uart_mux { + +static const char *const TAG = "uart_mux"; + +void UARTMux::setup() { + // A failed UART never assigned its port; nothing behind the mux can work. + if (this->uart_->is_failed()) { + ESP_LOGE(TAG, "UART parent failed; aborting"); + this->mark_failed(); + return; + } + + this->settings_ = { + this->uart_->get_baud_rate(), this->uart_->get_rx_full_threshold(), this->uart_->get_rx_timeout(), + this->uart_->get_rx_buffer_size(), this->uart_->get_data_bits(), this->uart_->get_stop_bits(), + this->uart_->get_parity(), + }; + this->apply_settings_(); + + if (this->start_local_) { + this->select_local(); + } else { + // loop() only completes hand-offs; the bridge keeps the UART until an action. + this->disable_loop(); + } +} + +void UARTMux::loop() { + if (!this->bridge_->is_paused()) { + return; + } + // Bytes that arrived during the hand-off belong to neither owner. + this->uart_->flush_input(); + this->route_ = Route::ROUTE_LOCAL; + ESP_LOGD(TAG, "UART routed to local consumers"); + this->disable_loop(); +} + +void UARTMux::dump_config() { + ESP_LOGCONFIG(TAG, + "UART Mux:\n" + " Start local: %s\n" + " Route: %s", + YESNO(this->start_local_), + this->route_ == Route::ROUTE_LOCAL ? LOG_STR_LITERAL("local") + : this->route_ == Route::ROUTE_PENDING_LOCAL ? LOG_STR_LITERAL("pending local") + : LOG_STR_LITERAL("bridge")); +} + +void UARTMux::load_settings(bool dump_config) { + if (!this->load_settings_warned_) { + this->load_settings_warned_ = true; + ESP_LOGW(TAG, "load_settings() ignored; change the framing on the hardware UART instead"); + } + // Undo whatever the caller set on us. Not re-sampled from the live UART, whose + // fields carry the host's line coding while the bridge owns the bus. + this->apply_settings_(); +} + +void UARTMux::apply_settings_() { + this->baud_rate_ = this->settings_.baud_rate; + this->data_bits_ = this->settings_.data_bits; + this->stop_bits_ = this->settings_.stop_bits; + this->parity_ = this->settings_.parity; + this->rx_full_threshold_ = this->settings_.rx_full_threshold; + this->rx_timeout_ = this->settings_.rx_timeout; + this->rx_buffer_size_ = this->settings_.rx_buffer_size; +} + +void UARTMux::select_local() { + if (this->route_ != Route::ROUTE_BRIDGE) { + return; + } + ESP_LOGD(TAG, "Pausing bridge to route UART locally"); + this->bridge_->pause(); + this->route_ = Route::ROUTE_PENDING_LOCAL; + this->enable_loop(); +} + +void UARTMux::select_bridge() { + if (this->route_ == Route::ROUTE_BRIDGE) { + return; + } + // A bridge that failed setup() has no worker tasks; handing it the bus would kill + // the UART in both directions. + if (this->bridge_->is_failed()) { + ESP_LOGW(TAG, "Bridge failed; keeping the UART routed locally"); + return; + } + // While the pause is still pending the bridge's RX task may be inside + // uart_read_bytes() on this port, and nothing local has run, so flush only a + // completed hand-off. + if (this->route_ == Route::ROUTE_LOCAL) { + this->uart_->flush_input(); + } + this->route_ = Route::ROUTE_BRIDGE; + ESP_LOGD(TAG, "UART routed to bridge"); + this->bridge_->resume(); + this->disable_loop(); +} + +void UARTMux::write_array(const uint8_t *data, size_t len) { + if (!this->is_local()) { + ESP_LOGV(TAG, "Dropping %zu bytes: UART routed to bridge", len); + return; + } + this->uart_->write_array(data, len); +} + +} // namespace esphome::uart_mux +#endif diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h new file mode 100644 index 0000000000..5b9815a3ca --- /dev/null +++ b/esphome/components/uart_mux/uart_mux.h @@ -0,0 +1,80 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/uart/uart_component_esp_idf.h" +#include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h" +#include "esphome/core/component.h" + +namespace esphome::uart_mux { + +/// Shares one hardware UART between a CDC-ACM UART bridge and local consumers. Local +/// consumers bind to the mux as their UART; it forwards to the hardware UART only +/// while routed locally and reports the route through is_connected(). Routing is +/// driven by the select_*() actions, typically from tinyusb's on_mount/on_unmount. +class UARTMux final : public uart::UARTComponent, public Component { + public: + explicit UARTMux(cdc_acm_uart::CDCACMUARTBridge *bridge) : uart_(bridge->get_uart_parent()), bridge_(bridge) {} + + void setup() override; + void loop() override; + void dump_config() override; + // Between the hardware UART (BUS) and its consumers (modbus is BUS - 1): the + // mirrored framing must exist before anything reads it from us. + float get_setup_priority() const override { return setup_priority::BUS - 0.5f; } + + /// Route locally at boot instead of leaving the UART with the bridge. + void set_start_local(bool start_local) { this->start_local_ = start_local; } + + /// Pause the bridge and route the UART to local consumers once it has stopped. + void select_local(); + /// Route the UART back to the bridge. + void select_bridge(); + bool is_local() const { return this->route_ == Route::ROUTE_LOCAL; } + + // uart::UARTComponent: forwarded while routed locally, inert otherwise. + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override { return this->is_local() && this->uart_->peek_byte(data); } + bool read_array(uint8_t *data, size_t len) override { return this->is_local() && this->uart_->read_array(data, len); } + size_t available() override { return this->is_local() ? this->uart_->available() : 0; } + uart::UARTFlushResult flush() override { + return this->is_local() ? this->uart_->flush() : uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; + } + bool is_connected() override { return this->is_local(); } + // Ignored: the bridge's tasks block inside the driver, and reinstalling it would + // pull it out from under them. The framing is the hardware UART's to change. + void load_settings(bool dump_config) override; + using UARTComponent::load_settings; + + protected: + enum class Route : uint8_t { + ROUTE_BRIDGE, + ROUTE_PENDING_LOCAL, // pause() requested; the bridge may still be on the bus + ROUTE_LOCAL, + }; + + // The hardware UART's settings as configured. Taken once at setup, before the + // bridge can overwrite the live fields with a host's line coding. + struct Settings { + uint32_t baud_rate; + size_t rx_full_threshold; + size_t rx_timeout; + size_t rx_buffer_size; + uint8_t data_bits; + uint8_t stop_bits; + uart::UARTParityOptions parity; + }; + + void check_logger_conflict() override {} + // Publish settings_ through the UARTComponent getters. + void apply_settings_(); + + uart::IDFUARTComponent *uart_; + cdc_acm_uart::CDCACMUARTBridge *bridge_; + Settings settings_{}; + Route route_{Route::ROUTE_BRIDGE}; + bool start_local_{false}; + bool load_settings_warned_{false}; +}; + +} // namespace esphome::uart_mux +#endif diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py new file mode 100644 index 0000000000..6e7a6fef32 --- /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 0000000000..4f241f8567 --- /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 0000000000..256e059133 --- /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 d96a731e9c..316d4fc311 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 858516c746..904ed97dc0 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 274e0119ee..17e27fb770 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 9d989ad4e6..6a632e05cd 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 0928fda9ee..a7db2b1425 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 c7e3b6f28d..6f205af784 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 85916f227e..2393524713 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 59583357e4..81d8db9a52 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 e3281f0b2d..581cdd4a9b 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 880132bad3..fbacd84db0 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 0000000000..f70ffa9520 --- /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 0000000000..f5b4084fe0 --- /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 0000000000..9e69d036e0 --- /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 5ebe58881d..1533a893ba 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 8ba7b71a9c..0000000000 --- a/esphome/components/update/automation.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "update_entity.h" - -#include "esphome/core/automation.h" - -namespace esphome::update { - -template class PerformAction final : public Action, public Parented { - TEMPLATABLE_VALUE(bool, force) - - public: - void play(const Ts &...x) override { this->parent_->perform(this->force_.value(x...)); } -}; - -template class CheckAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->check(); } -}; - -template class IsAvailableCondition final : public Condition, public Parented { - public: - bool check(const Ts &...x) override { return this->parent_->state == UPDATE_STATE_AVAILABLE; } -}; - -} // namespace esphome::update diff --git a/esphome/components/update/update_entity.h b/esphome/components/update/update_entity.h index f925d338ff..96ba6dbd56 100644 --- a/esphome/components/update/update_entity.h +++ b/esphome/components/update/update_entity.h @@ -16,7 +16,7 @@ struct UpdateInfo { std::string firmware_url; std::string md5; bool has_progress{false}; - float progress; + float progress{0}; }; enum UpdateState : uint8_t { diff --git a/esphome/components/uponor_smatrix/sensor/__init__.py b/esphome/components/uponor_smatrix/sensor/__init__.py index 52e755f005..8167506d78 100644 --- a/esphome/components/uponor_smatrix/sensor/__init__.py +++ b/esphome/components/uponor_smatrix/sensor/__init__.py @@ -67,15 +67,8 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await register_uponor_smatrix_device(var, config) - if temperature_config := config.get(CONF_TEMPERATURE): - sens = await sensor.new_sensor(temperature_config) - cg.add(var.set_temperature_sensor(sens)) - if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): - sens = await sensor.new_sensor(external_temperature_config) - cg.add(var.set_external_temperature_sensor(sens)) - if humidity_config := config.get(CONF_HUMIDITY): - sens = await sensor.new_sensor(humidity_config) - cg.add(var.set_humidity_sensor(sens)) - if target_temperature_config := config.get(CONF_TARGET_TEMPERATURE): - sens = await sensor.new_sensor(target_temperature_config) - cg.add(var.set_target_temperature_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_TARGET_TEMPERATURE, var.set_target_temperature_sensor) diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.h b/esphome/components/uptime/sensor/uptime_seconds_sensor.h index b0b12954b2..92d475e62e 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.h +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.h @@ -7,6 +7,8 @@ namespace esphome::uptime { class UptimeSecondsSensor final : public sensor::Sensor, public PollingComponent { public: + // User provided, not "= default": `new(p) UptimeSecondsSensor()` would zero-fill .bss that is already zero. + UptimeSecondsSensor() {} void update() override; void dump_config() override; diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h index d8eb91586a..83cb5de89f 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.h +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -7,15 +7,47 @@ #include "esphome/core/lock_free_queue.h" #include "esphome/components/uart/uart_component.h" +#include #include +#include #include #include "freertos/ringbuf.h" +#include "esp_err.h" #include "tinyusb_cdc_acm.h" namespace esphome::usb_cdc_acm { static const uint8_t EVENT_QUEUE_SIZE = 12; +// Drain up to out_buf_sz bytes from a byte ring buffer, handling FreeRTOS's wrapped +// case with a second read. Shared with the cdc_acm_uart bridge platform, whose worker +// tasks drain the same ring buffers. +inline esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, + TickType_t x_ticks_to_wait) { + size_t read_sz; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); + + if (buf == nullptr) { + return ESP_FAIL; + } + + memcpy(out_buf, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size = read_sz; + + // Buffer's data can be wrapped, in which case we should perform another read + if (*rx_data_size < out_buf_sz) { + buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); + if (buf != nullptr) { + memcpy(out_buf + *rx_data_size, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size += read_sz; + } + } + + return ESP_OK; +} + // Callback types for line coding and line state changes using LineCodingCallback = std::function; using LineStateCallback = std::function; @@ -103,6 +135,8 @@ class USBCDCACMInstance final : public uart::UARTComponent, public Parented usb_tx_staging_{}; // Non-zero while the TX task holds bytes it has pulled from the ring buffer but not // yet handed to TinyUSB; lets flush() account for data that is in neither the ring // buffer nor TinyUSB's FIFO. diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp index e46369660d..7aa7b46b7b 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -104,30 +104,6 @@ static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *ev instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits); } -static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, - TickType_t x_ticks_to_wait) { - size_t read_sz; - uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, x_ticks_to_wait, out_buf_sz)); - - if (buf == nullptr) { - return ESP_FAIL; - } - - memcpy(out_buf, buf, read_sz); - vRingbufferReturnItem(ring_buf, (void *) buf); - *rx_data_size = read_sz; - - // Buffer's data can be wrapped, in which case we should perform another read - buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); - if (buf != nullptr) { - memcpy(out_buf + *rx_data_size, buf, read_sz); - vRingbufferReturnItem(ring_buf, (void *) buf); - *rx_data_size += read_sz; - } - - return ESP_OK; -} - //============================================================================== // USBCDCACMInstance Implementation //============================================================================== @@ -192,7 +168,7 @@ void USBCDCACMInstance::usb_tx_task_fn(void *arg) { } void USBCDCACMInstance::usb_tx_task() { - uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; + uint8_t *data = this->usb_tx_staging_.data(); size_t tx_data_size = 0; // Back-dated so a stall within the first LOG_THROTTLE_MS of uptime still logs // immediately (unsigned arithmetic keeps this wrap-safe). diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 4abcc3a449..94532958ed 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 42869fb2a6..aaab8e22a1 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 7bc2b0a16b..1b50aa75ba 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 edbf75f70f..a632b4e38d 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, @@ -44,6 +55,7 @@ UART_STOP_BITS_OPTIONS = { } DEFAULT_BAUD_RATE = 9600 +CONF_CLAIM_COMM_INTERFACE = "claim_comm_interface" class Type: @@ -56,15 +68,21 @@ class Type: max_channels: int = 1, baud_rate_required: bool = True, max_baud: int = 1_000_000, + has_comm_interface: bool = False, ) -> None: self.name = name cls = cls or name 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 + # True for types that claim the CDC comm (interrupt) interface; only these + # accept the claim_comm_interface option. + self.has_comm_interface = has_comm_interface @property def max_channels(self) -> int: @@ -80,11 +98,21 @@ class Type: uart_types = ( - Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), + Type( + "CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False, has_comm_interface=True + ), Type("CH34X", 0x1A86, 0x55D5, "CH34X", 4, max_baud=2_000_000), Type("CH340", 0x1A86, 0x7523, "CH34X", 1, max_baud=2_000_000), Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3, max_baud=2_000_000), - Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type( + "ESP_JTAG", + 0x303A, + 0x1001, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), Type("FT232", 0x0403, 0x6001, "FT23XX", 1, max_baud=3_000_000), Type("FT2232", 0x0403, 0x6010, "FT23XX", 2, max_baud=12_000_000), Type("FT4232", 0x0403, 0x6011, "FT23XX", 4, max_baud=12_000_000), @@ -95,12 +123,35 @@ uart_types = ( Type("PL2303GL", 0x067B, 0x23D3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GS", 0x067B, 0x23F3, "PL2303", 1, max_baud=6_000_000), Type("PL2303GT", 0x067B, 0x23C3, "PL2303", 1, max_baud=6_000_000), - Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), + Type( + "STM32_VCP", + 0x0483, + 0x5740, + "CdcAcm", + 1, + baud_rate_required=False, + has_comm_interface=True, + ), +) + +_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: - return cv.Schema( + schema = cv.Schema( { cv.Required(CONF_CHANNELS): cv.All( cv.ensure_list( @@ -139,9 +190,26 @@ def channel_schema(type_: "Type") -> cv.Schema: max=type_.max_channels, msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels", ), - ) + ), } ) + if type_.has_comm_interface: + # The comm (interrupt) interface pins a host hardware channel per device; + # disable to save one on channel-poor hosts (some devices may need it + # claimed before enabling data flow). + schema = schema.extend( + {cv.Optional(CONF_CLAIM_COMM_INTERFACE, default=True): cv.boolean} + ) + else: + schema = schema.extend( + { + cv.Optional(CONF_CLAIM_COMM_INTERFACE): cv.invalid( + f"'{CONF_CLAIM_COMM_INTERFACE}' is only supported on device types " + f"that claim the CDC comm interface; {type_.name} never claims it" + ) + } + ) + return schema CONFIG_SCHEMA = cv.ensure_list( @@ -171,7 +239,12 @@ 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): + cg.add(var.set_claim_comm_interface(False)) for index, channel in enumerate(device[CONF_CHANNELS]): chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) await cg.register_parented(chvar, var) diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 60b7fe4e9c..86dfa6d729 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) { @@ -431,15 +429,20 @@ void USBUartTypeCdcAcm::on_connected() { // they enable data flow on the bulk endpoints. if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { - auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, - channel->cdc_dev_.interrupt_interface_number, 0); - if (err_comm != ESP_OK) { - // Continue anyway: the interface number stays valid for CDC request addressing - ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, - esp_err_to_name(err_comm)); + if (!this->claim_comm_interface_) { + ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", + channel->cdc_dev_.interrupt_interface_number); } else { - ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); - channel->cdc_dev_.interrupt_interface_claimed = true; + auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, + channel->cdc_dev_.interrupt_interface_number, 0); + if (err_comm != ESP_OK) { + // Continue anyway: the interface number stays valid for CDC request addressing + ESP_LOGW(TAG, "Could not claim comm interface %d: %s", channel->cdc_dev_.interrupt_interface_number, + esp_err_to_name(err_comm)); + } else { + ESP_LOGD(TAG, "Claimed comm interface %d", channel->cdc_dev_.interrupt_interface_number); + channel->cdc_dev_.interrupt_interface_claimed = true; + } } } auto err = @@ -458,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); @@ -489,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(); } @@ -655,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 9d87bf964c..c6ae0b4eef 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 @@ -271,12 +298,16 @@ class USBUartComponent : public usb_host::USBClient { class USBUartTypeCdcAcm : public USBUartComponent { public: USBUartTypeCdcAcm(uint16_t vid, uint16_t pid) : USBUartComponent(vid, pid) {} + void set_claim_comm_interface(bool claim) { this->claim_comm_interface_ = claim; } protected: virtual std::vector parse_descriptors(usb_device_handle_t dev_hdl); void on_connected() override; void on_disconnected() override; bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; + // Each claimed interface pins one host hardware channel per endpoint; skipping + // the comm (interrupt) interface frees one on channel-poor hosts (ESP32-S3: 8). + bool claim_comm_interface_{true}; }; class USBUartTypeCP210X : public USBUartTypeCdcAcm { diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 7d98af402d..7df58c7a2a 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 63d03a889b..598bbc1678 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 5c09a025f8..d3b26d64d8 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 e8a6ea7bfa..84e7890090 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 4afca4b868..9ed98fc4df 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 cad239dc41..189d18256f 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 96f72ad035..8537435079 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -7,14 +7,16 @@ namespace esphome::version { class VersionTextSensor final : public text_sensor::TextSensor, public Component { public: + // User provided, not "= default": `new(p) VersionTextSensor()` would zero-fill .bss that is already zero. + VersionTextSensor() {} void set_hide_hash(bool hide_hash) { this->hide_hash_ = hide_hash; } void set_hide_timestamp(bool hide_timestamp) { this->hide_timestamp_ = hide_timestamp; } void setup() override; 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 d30eaf4768..14c036de8e 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 d46b089c2e..ae5eee3e22 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 e46b96c86a..fd94f56b75 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 1dc2d008a1..4b65e907de 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -161,6 +161,7 @@ void WaterHeaterCall::validate_() { } void WaterHeater::publish_state() { + this->set_has_state(true); auto traits = this->get_traits(); ESP_LOGV(TAG, "'%s' >>\n" diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 14ff5ed53c..93f23424c0 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -2183,8 +2183,6 @@ void GDEW029T5::write_lut_(const uint8_t *lut, const uint8_t size) { this->end_data_(); } -void GDEW029T5::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } - int GDEW029T5::get_width_internal() { return 128; } int GDEW029T5::get_height_internal() { return 296; } void GDEW029T5::dump_config() { @@ -2523,7 +2521,6 @@ void HOT GDEY042T81::display() { ESP_LOGD(TAG, "Set the display back to deep sleep"); this->deep_sleep(); } -void GDEY042T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY042T81::get_width_internal() { return 400; } int GDEY042T81::get_height_internal() { return 300; } uint32_t GDEY042T81::idle_timeout_() { return 5000; } @@ -3156,7 +3153,6 @@ void HOT GDEY0583T81::display() { this->deep_sleep(); } -void GDEY0583T81::set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } int GDEY0583T81::get_width_internal() { return 648; } int GDEY0583T81::get_height_internal() { return 480; } uint32_t GDEY0583T81::idle_timeout_() { return 5000; } diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.h b/esphome/components/waveshare_epaper/waveshare_epaper.h index fa3737238e..7e16ce3dc3 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.h +++ b/esphome/components/waveshare_epaper/waveshare_epaper.h @@ -272,7 +272,7 @@ class GDEW029T5 : public WaveshareEPaper { void dump_config() override; void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: void init_display_(); @@ -503,7 +503,7 @@ class GDEY042T81 : public WaveshareEPaper { this->data(0x01); } - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: uint32_t full_update_every_{30}; @@ -695,7 +695,7 @@ class GDEY0583T81 : public WaveshareEPaper { void deep_sleep() override; - void set_full_update_every(uint32_t full_update_every); + void set_full_update_every(uint32_t full_update_every) { this->full_update_every_ = full_update_every; } protected: int get_width_internal() override; diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2459163786..6bc4d7ab5c 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 58fecd04d8..fde86502d4 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1,11 +1,12 @@ #include "web_server.h" + +#include #ifdef USE_WEBSERVER #include "esphome/components/json/json_util.h" #include "esphome/core/progmem.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/defines.h" -#include "esphome/core/controller_registry.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -59,6 +60,12 @@ static const char *const TAG = "web_server"; // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +// Out of line: every GET handler ends with this +[[maybe_unused]] static void send_json(AsyncWebServerRequest *request, json::JsonBuilder &builder) { + auto data = builder.serialize(); + request->send(200, ESPHOME_F("application/json"), data.c_str()); +} + // Parse URL and return match info // URL formats (disambiguated by HTTP method for 3-segment case): // GET /{domain}/{entity_name} - main device state @@ -66,9 +73,12 @@ static const char *const TAG = "web_server"; // GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only) // POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only) static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) { + // Every path returns this one object so it is built in place; fields are only set once the URL is known valid + UrlMatch match{}; + // URL must start with '/' and have content after it if (url_len < 2 || url_ptr[0] != '/') - return UrlMatch{}; + return match; const char *p = url_ptr + 1; const char *end = url_ptr + url_len; @@ -90,15 +100,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Must have domain with trailing slash if (!s2) - return UrlMatch{}; - - UrlMatch match{}; - match.domain = make_ref(s1, s2); - match.valid = true; - - if (only_domain || s2 >= end) return match; + if (only_domain || s2 >= end) { + match.domain = make_ref(s1, s2); + match.valid = true; + return match; + } + // Parse remaining segments only when needed const char *s3 = next_segment(s2); const char *s4 = s3 ? next_segment(s3) : nullptr; @@ -109,7 +118,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Reject empty segments if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty())) - return UrlMatch{}; + return match; // Interpret based on segment count if (!s3) { @@ -121,28 +130,31 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, if (is_post) { match.id = seg2; match.method = seg3; - return match; - } + } else { #ifdef USE_DEVICES - match.device_name = seg2; - match.id = seg3; + match.device_name = seg2; + match.id = seg3; #else - return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES + return match; // 3-segment GET not supported without USE_DEVICES #endif + } } else { // 3 segments after domain: /{domain}/{device}/{entity}/{action} #ifdef USE_DEVICES if (!is_post) { - return UrlMatch{}; // 4-segment GET not supported (action requires POST) + return match; // 4-segment GET not supported (action requires POST) } match.device_name = seg2; match.id = seg3; match.method = seg4; #else - return UrlMatch{}; // Not supported without USE_DEVICES + // Not supported without USE_DEVICES + return match; #endif } + match.domain = make_ref(s1, s2); + match.valid = true; return match; } @@ -189,16 +201,18 @@ DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_gener void DeferredUpdateEventSource::process_deferred_queue_() { while (!deferred_queue_.empty()) { DeferredEvent &de = deferred_queue_.front(); - auto message = de.message_generator_(web_server_, de.source_); + json::JsonBuilder builder; + de.message_generator_(web_server_, de.source_, builder); + auto message = builder.serialize(); if (this->send(message.c_str(), "state") != DISCARDED) { // O(n) but memory efficiency is more important than speed here which is why std::vector was chosen deferred_queue_.erase(deferred_queue_.begin()); this->consecutive_send_failures_ = 0; // Reset failure count on successful send } else { - // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::process_buffer_() - // The implementations differ due to platform-specific APIs (DISCARDED vs HTTPD_SOCK_ERR_TIMEOUT, close() vs - // fd_.store(0)), but the failure counting and timeout logic should be kept in sync. If you change this logic, - // also update the ESP-IDF implementation. + // NOTE: Similar logic exists in web_server_idf/web_server_idf.cpp in AsyncEventSourceResponse::drain_tail_(). + // The close mechanisms are platform-specific (this path calls close() directly; the IDF path is time-based and + // closes through HTTPD to preserve session ownership), but both drop a client after roughly 20 seconds without + // send progress. Keep that stall policy in sync when changing either side. this->consecutive_send_failures_++; if (this->consecutive_send_failures_ >= MAX_CONSECUTIVE_SEND_FAILURES) { // Too many failures, connection is likely dead @@ -246,7 +260,9 @@ void DeferredUpdateEventSource::deferrable_send_state(void *source, const char * // deferred queue still not empty which means downstream event queue full, no point trying to send first deq_push_back_with_dedup_(source, message_generator); } else { - auto message = message_generator(web_server_, source); + json::JsonBuilder builder; + message_generator(web_server_, source, builder); + auto message = builder.serialize(); if (this->send(message.c_str(), "state") == DISCARDED) { deq_push_back_with_dedup_(source, message_generator); } else { @@ -336,12 +352,8 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} -#ifdef USE_WEBSERVER_CSS_INCLUDE -void WebServer::set_css_include(const char *css_include) { this->css_include_ = css_include; } -#endif -#ifdef USE_WEBSERVER_JS_INCLUDE -void WebServer::set_js_include(const char *js_include) { this->js_include_ = js_include; } -#endif +// Kept out of the callers so the 64 bit division is emitted once +__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; @@ -350,7 +362,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); - root[ESPHOME_F("comment")] = comment_buffer; + root[ESPHOME_F("comment")] = static_cast(comment_buffer); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else @@ -358,13 +370,12 @@ json::SerializationBuffer<> WebServer::get_config_json() { #endif root[ESPHOME_F("log")] = this->expose_log_; root[ESPHOME_F("lang")] = "en"; - root[ESPHOME_F("uptime")] = static_cast(millis_64() / 1000); + root[ESPHOME_F("uptime")] = uptime_seconds(); return builder.serialize(); } void WebServer::setup() { - ControllerRegistry::register_controller(this); this->base_->init(); #ifdef USE_LOGGER @@ -389,7 +400,7 @@ void WebServer::setup() { if (this->events_.empty()) return; char buf[32]; - auto uptime = static_cast(millis_64() / 1000); + auto uptime = uptime_seconds(); size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); @@ -406,10 +417,11 @@ void WebServer::loop() { } #ifdef USE_LOGGER + void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - this->events_.try_send_nodefer(message, message_len, "log", millis()); + this->events_.try_send_nodefer(message, std::min(message_len, LOG_EVENT_MAX_LEN), "log", millis()); } #endif @@ -474,13 +486,17 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const const size_t scheme_sep = origin.find("://"); if (scheme_sep != std::string::npos) { const std::string host = get_request_header(request, "Host"); - if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + // Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here + const size_t authority = scheme_sep + 3; + if (!host.empty() && origin.size() - authority == host.size() && + memcmp(origin.data() + authority, host.data(), host.size()) == 0) return true; } #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; @@ -541,7 +557,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion // Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name} // Uses names (not object_id) to avoid UTF-8 collision issues -static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) { +static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) { const StringRef &name = obj->get_name(); size_t prefix_len = strlen(prefix); size_t name_len = name.size(); @@ -576,7 +592,7 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("id")] = id_buf; + root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { root[ESPHOME_F("domain")] = prefix; @@ -601,14 +617,13 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Keep as separate function even though only used once: reduces code size by ~48 bytes // by allowing compiler to share code between template instantiations (bool, float, etc.) template -static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, - JsonDetail start_config) { +static void set_json_value(JsonObject root, EntityBase *obj, const char *prefix, T value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); root[ESPHOME_F("value")] = value; } template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, +static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const char *prefix, S state, T value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; @@ -633,21 +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(); @@ -661,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 @@ -680,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 @@ -741,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; } @@ -766,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); @@ -781,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 @@ -794,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); @@ -807,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 @@ -837,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 @@ -880,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); @@ -921,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); @@ -942,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 @@ -961,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); @@ -1002,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) { @@ -1023,8 +1026,6 @@ json::SerializationBuffer<> WebServer::light_json_(light::LightState *obj, JsonD } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1042,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; } @@ -1090,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, @@ -1112,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 @@ -1131,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"))) { @@ -1150,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(); @@ -1180,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 @@ -1198,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"))) { @@ -1224,25 +1222,22 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat request->send(404); } -json::SerializationBuffer<> WebServer::date_state_json_generator(WebServer *web_server, void *source) { - return web_server->date_json_((datetime::DateEntity *) (source), DETAIL_STATE); +void WebServer::date_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->date_json_((datetime::DateEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::date_all_json_generator(WebServer *web_server, void *source) { - return web_server->date_json_((datetime::DateEntity *) (source), DETAIL_ALL); +void WebServer::date_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->date_json_((datetime::DateEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: YYYY-MM-DD (max 10 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day); - set_json_icon_state_value(root, obj, "date", value, value, start_config); + set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATE @@ -1259,8 +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"))) { @@ -1284,25 +1280,22 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat } request->send(404); } -json::SerializationBuffer<> WebServer::time_state_json_generator(WebServer *web_server, void *source) { - return web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_STATE); +void WebServer::time_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::time_all_json_generator(WebServer *web_server, void *source) { - return web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_ALL); +void WebServer::time_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: HH:MM:SS (8 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "time", value, value, start_config); + set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_TIME @@ -1319,8 +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"))) { @@ -1344,26 +1338,23 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur } request->send(404); } -json::SerializationBuffer<> WebServer::datetime_state_json_generator(WebServer *web_server, void *source) { - return web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_STATE); +void WebServer::datetime_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::datetime_all_json_generator(WebServer *web_server, void *source) { - return web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_ALL); +void WebServer::datetime_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: YYYY-MM-DD HH:MM:SS (max 19 chars + null) char value[24]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "datetime", value, value, start_config); + set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATETIME @@ -1381,8 +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"))) { @@ -1402,14 +1394,14 @@ 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(); // A password entity shows the mask and prefills the input with nothing, so the secret never @@ -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,21 +2271,20 @@ void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::update_state_json_generator(WebServer *web_server, void *source) { +void WebServer::update_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE); + web_server->update_json_((update::UpdateEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::update_all_json_generator(WebServer *web_server, void *source) { +void WebServer::update_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - return web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL); + web_server->update_json_((update::UpdateEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config) { +void WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson - json::JsonBuilder builder; JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version, start_config); + obj->update_info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; root[ESPHOME_F("title")] = obj->update_info.title; @@ -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 0fbe4ec551..1f9b6cb934 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -6,8 +6,9 @@ #include "esphome/components/web_server_base/web_server_base.h" #ifdef USE_WEBSERVER #include "esphome/core/component.h" -#include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#include "esphome/core/entity_includes.h" +#include "esphome/core/progmem.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -36,12 +37,12 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome::web_server { -// Type for parameter names that can be stored in flash on ESP8266 -#ifdef USE_ESP8266 -using ParamNameType = const __FlashStringHelper *; -#else -using ParamNameType = const char *; -#endif +// ESP-IDF cuts a log event here: nothing a browser log view needs is longer, and it bounds the +// tail a stalled client keeps. The Arduino backend takes a C string and sends the whole line. +constexpr size_t LOG_EVENT_MAX_LEN = 512; + +// Parameter names live in flash on ESP8266 +using ParamNameType = ProgmemStr; // All platforms need to defer actions to main loop thread. // Multi-core platforms need this for thread safety. @@ -104,7 +105,7 @@ enum JsonDetail { DETAIL_ALL, DETAIL_STATE }; can be forgotten. */ #if !defined(USE_ESP32) && defined(USE_ARDUINO) -using message_generator_t = json::SerializationBuffer<>(WebServer *, void *); +using message_generator_t = void(WebServer *, void *, json::JsonBuilder &); class DeferredUpdateEventSourceList; class DeferredUpdateEventSource final : public AsyncEventSource { @@ -190,7 +191,7 @@ class DeferredUpdateEventSourceList final : public std::listcss_url_ = css_url; } /** Set the URL to the script that's embedded in the index page. Defaults to * https://oi.esphome.io/v1/webserver-v1.min.js * * @param js_url The url to the web server script. */ - void set_js_url(const char *js_url); + void set_js_url(const char *js_url) { this->js_url_ = js_url; } #endif #ifdef USE_WEBSERVER_CSS_INCLUDE @@ -219,7 +220,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param css_include Local path to web server script. */ - void set_css_include(const char *css_include); + void set_css_include(const char *css_include) { this->css_include_ = css_include; } #endif #ifdef USE_WEBSERVER_JS_INCLUDE @@ -227,7 +228,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * @param js_include Local path to web server script. */ - void set_js_include(const char *js_include); + void set_js_include(const char *js_include) { this->js_include_ = js_include; } #endif /** Determine whether internal components should be displayed on the web server. @@ -253,9 +254,9 @@ class WebServer final : public Controller, public Component, public AsyncWebHand * * This list is also used to authorize Private Network Access requests when that feature is enabled. * - * @param origins The list of allowed origins. + * @param origins nullptr-terminated table of allowed origins; must outlive the server. */ - void set_allowed_origins(std::initializer_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 @@ -593,7 +602,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand web_server_base::WebServerBase *base_; #ifdef USE_ESP32 - AsyncEventSource events_{"/events", this}; + AsyncEventSource events_{StringRef::from_lit("/events"), this}; #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif @@ -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/web_server_v1.cpp b/esphome/components/web_server/web_server_v1.cpp index 85a4e80541..08654e353a 100644 --- a/esphome/components/web_server/web_server_v1.cpp +++ b/esphome/components/web_server/web_server_v1.cpp @@ -69,10 +69,6 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string & stream->print(""); } -void WebServer::set_css_url(const char *css_url) { this->css_url_ = css_url; } - -void WebServer::set_js_url(const char *js_url) { this->js_url_ = js_url; } - void WebServer::handle_index_request(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("text/html")); const auto &title = App.get_name(); diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 5a400dfbf3..b9d079b53f 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 0000000000..a312b64c03 --- /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 0000000000..c8a4170008 --- /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 9550570cdc..a35ad65214 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 6469b4c564..581ba8323b 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,20 +342,46 @@ 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(std::string url, esphome::web_server::WebServer *ws) : url_(std::move(url)), web_server_(ws) {} + AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} ~AsyncEventSource() override; // NOLINTNEXTLINE(readability-identifier-naming) @@ -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); @@ -352,13 +406,12 @@ class AsyncEventSource : public AsyncWebHandler { // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); - std::string url_; + StringRef url_; // Must outlive this object (string literal) // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. 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 64eef46f74..f357bf75b3 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,6 +12,8 @@ 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 ( add_use_address, @@ -211,6 +213,7 @@ WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action) WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action) +WiFiRoamAction = wifi_ns.class_("WiFiRoamAction", automation.Action) WiFiConfigureAction = wifi_ns.class_( "WiFiConfigureAction", automation.Action, cg.Component ) @@ -352,7 +355,7 @@ def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config full_config = fv.full_config.get() - has_improv = "esp32_improv" in full_config + has_improv = "improv_ble" in full_config has_improv_serial = "improv_serial" in full_config has_captive_portal = "captive_portal" in full_config has_web_server = "web_server" in full_config @@ -366,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: @@ -505,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, @@ -660,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. @@ -669,6 +683,12 @@ async def to_code(config): # Disable Enterprise WiFi support if no EAP is configured if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) + if has_eap: + # 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 if has_manual_ip: @@ -719,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 ( @@ -763,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) @@ -821,33 +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({}), +) + + +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_disable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index e63faa18ab..c14341330f 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -31,6 +31,11 @@ template class WiFiDisableAction final : public Action { void play(const Ts &...x) override { global_wifi_component->disable(); } }; +template class WiFiRoamAction final : public Action { + public: + void play(const Ts &...x) override { global_wifi_component->force_roam_check(); } +}; + template class WiFiConfigureAction final : public Action, public Component { public: TEMPLATABLE_VALUE(std::string, ssid) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index f290832a18..fd982c0d31 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -37,8 +37,8 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_IMPROV -#include "esphome/components/esp32_improv/esp32_improv_component.h" +#ifdef USE_IMPROV_BLE +#include "esphome/components/improv_ble/improv_ble_component.h" #endif #ifdef USE_IMPROV_SERIAL @@ -226,7 +226,7 @@ bool CompactString::operator==(const StringRef &other) const { /// ┌──────────────────────────────────────────────────────────────────────┐ /// │ Captive Portal / Improv Mode (AP active, scanning disabled) │ /// ├──────────────────────────────────────────────────────────────────────┤ -/// │ When captive_portal or esp32_improv is active, WiFi scanning is │ +/// │ When captive_portal or improv_ble is active, WiFi scanning is │ /// │ disabled because it disrupts AP clients (radio leaves AP channel │ /// │ to hop through other channels, causing client disconnections). │ /// │ │ @@ -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 @@ -478,9 +497,9 @@ bool WiFiComponent::needs_full_scan_results_() const { } #endif -#ifdef USE_IMPROV +#ifdef USE_IMPROV_BLE // BLE improv also needs results during provisioning - if (esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active()) { + if (improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active()) { return true; } #endif @@ -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,17 +766,20 @@ 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 #endif // USE_WIFI_AP } -#ifdef USE_IMPROV - if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) { +#ifdef USE_IMPROV_BLE + if (!this->has_sta() && improv_ble::global_improv_component != nullptr) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } #endif this->wifi_apply_hostname_(); @@ -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; } @@ -805,7 +835,7 @@ void WiFiComponent::loop() { break; } // Use longer cooldown when captive portal/improv is active to avoid disrupting user config - bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); + bool portal_active = this->is_captive_portal_active_() || this->is_improv_ble_active_(); uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; if (now - this->action_started_ > cooldown_duration) { // After cooldown we either restarted the adapter because of @@ -846,23 +876,33 @@ void WiFiComponent::loop() { this->notify_connect_state_listeners_(); #endif - // Post-connect roaming: check for better AP - if (this->post_connect_roaming_) { - if (this->is_roaming_scan_active()) { - if (this->scan_done_) { - this->process_roaming_scan_(); - } - // else: scan in progress, wait - } else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS && - now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) { - this->check_roaming_(now); + // Post-connect roaming: check for better AP. A scan may have been started by an + // explicit force_roam_check() even when post_connect_roaming_ is disabled, so the + // scan must always be consumed here to avoid leaving roaming_state_ stuck. + if (this->is_roaming_scan_active()) { + if (this->scan_done_) { + this->process_roaming_scan_(); } + // else: scan in progress, wait + } else if (this->post_connect_roaming_ && this->roaming_state_ == RoamingState::IDLE && + this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS && + now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) { + this->check_roaming_(now); } } break; } - 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; @@ -878,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; @@ -893,12 +945,12 @@ void WiFiComponent::loop() { } #endif // USE_WIFI_AP -#ifdef USE_IMPROV - if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() && - !esp32_improv::global_improv_component->should_start()) { - if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { +#ifdef USE_IMPROV_BLE + if (improv_ble::global_improv_component != nullptr && !improv_ble::global_improv_component->is_active() && + !improv_ble::global_improv_component->should_start()) { + if (now - this->last_connected_ > improv_ble::global_improv_component->get_wifi_timeout()) { if (this->wifi_mode_(true, {})) - esp32_improv::global_improv_component->start(); + improv_ble::global_improv_component->start(); } } @@ -971,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; @@ -1005,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, @@ -1029,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; @@ -1125,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"); @@ -1217,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)) { @@ -1634,18 +1739,14 @@ 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_(); } -#ifdef USE_IMPROV - if (this->is_esp32_improv_active_()) { - esp32_improv::global_improv_component->stop(); +#endif +#ifdef USE_IMPROV_BLE + if (this->is_improv_ble_active_()) { + improv_ble::global_improv_component->stop(); } #endif @@ -1877,7 +1978,7 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { return WiFiRetryPhase::RETRY_HIDDEN; } // Need to scan for captive portal - } else if (this->is_esp32_improv_active_()) { + } else if (this->is_improv_ble_active_()) { // Improv doesn't need scan results return WiFiRetryPhase::RETRY_HIDDEN; } @@ -1968,7 +2069,7 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // Skip actual adapter restart if captive portal/improv is active // This allows state machine to reset num_retried_ and trigger fresh scan // without disrupting the captive portal/improv connection - if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { + if (!this->is_captive_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2227,9 +2328,9 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } -bool WiFiComponent::is_esp32_improv_active_() { -#ifdef USE_IMPROV - return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active(); +bool WiFiComponent::is_improv_ble_active_() { +#ifdef USE_IMPROV_BLE + return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); #else return false; #endif @@ -2463,6 +2564,17 @@ void WiFiComponent::notify_scan_results_listeners_() { } #endif // USE_WIFI_SCAN_RESULTS_LISTENERS +void WiFiComponent::force_roam_check() { + if (!this->is_connected() || this->roaming_state_ != RoamingState::IDLE || this->roaming_suppressed_()) { + ESP_LOGD(TAG, "Roam check requested, but not able to check now"); + return; + } + // Reset the attempt counter so a prior run of failed roams doesn't block this explicit request + // Note that this re-arms automatic roaming if enabled. + this->roaming_attempts_ = 0; + this->check_roaming_(millis()); +} + void WiFiComponent::check_roaming_(uint32_t now) { // Guard: not for hidden networks (may not appear in scan) const WiFiAP *selected = this->get_selected_sta_(); @@ -2484,7 +2596,11 @@ void WiFiComponent::check_roaming_(uint32_t now) { ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); this->roaming_state_ = RoamingState::SCANNING; - this->wifi_scan_start_(this->passive_scan_); + if (!this->wifi_scan_start_(this->passive_scan_)) { + // Scan failed to start (e.g. busy) - don't get stuck in SCANNING forever + ESP_LOGD(TAG, "Roam scan failed to start"); + this->roaming_state_ = RoamingState::IDLE; + } } void WiFiComponent::process_roaming_scan_() { diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 8bf4581413..9700e36e6f 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(); @@ -565,6 +568,12 @@ class WiFiComponent final : public Component { void set_keep_scan_results(bool keep_scan_results) { this->keep_scan_results_ = keep_scan_results; } void set_post_connect_roaming(bool enabled) { this->post_connect_roaming_ = enabled; } + /** Force an immediate post-connect roaming check, bypassing the periodic interval and the + * per-connection attempt limit. Does nothing (besides a debug log) if not connected, if a + * roam scan or connect is already in progress, or if roaming is currently suppressed. + */ + void force_roam_check(); + #ifdef USE_WIFI_CONNECT_TRIGGER Trigger<> *get_connect_trigger() { return &this->connect_trigger_; } #endif @@ -678,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_(); @@ -791,7 +806,7 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); - bool is_esp32_improv_active_(); + bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT bool load_fast_connect_settings_(WiFiAP ¶ms); @@ -868,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 @@ -913,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 @@ -977,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. @@ -1001,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 60ec3f9a4d..aa1fa67b7b 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 24bf64a99c..c44ee5b1de 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 940f2a0783..28b30628fd 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 ea9e5a3b0c..3b67aa46d0 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 2ba59d4c39..a1fd366ccf 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 4c9d601eee..49146025af 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 e48678f5bf..7beb4b08f0 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 fc06569fba..1fd610f4e8 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 c9c2feb7ae..faf63cd444 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 c629e46650..df274ac09b 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 3a24d63760..6deab23828 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 83c20dfbd8..eb3b042cb7 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 0000000000..3d75ef24ec --- /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 0000000000..cb0802d270 --- /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 0000000000..7f9cd17d1f --- /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 0000000000..4113bd6d26 --- /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 09337df5ca..162ba7016b 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 83de32f411..e1d44d6bac 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 3f0df9eafe..01ca8d1f84 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 219432c1fc..b742f1eaec 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 63ba5da597..7513897024 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 13e8e509c3..11bbceef5e 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 d1ce8dfcf7..c5d256db1d 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 061753c44c..73d5f3ff19 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 b791c40dca..4bfe9c8f75 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 f79b81dce2..d37d7540c7 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 05e0204def..5b088318ae 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 f4e24a97d0..32977d0ed6 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 8f0b93a22e..75a8a2d847 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 0000000000..3aecbd8207 --- /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 0000000000..f410ad7f73 --- /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 0000000000..88b9865e47 --- /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 0000000000..8897752b44 --- /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 9ce544fc59..44fb9c7654 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 52cae36fcd..e74c656e51 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 fa4296d7a9..c6277a515d 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 5d562a2ca9..be5827c171 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 57420125cb..c122b1ce98 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 e0e4b4640b..5e849b26bc 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 4a6267f8b9..8e9b32b797 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 8282523129..8c47b526b1 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 5686b74173..2656b60c10 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 29ba0b9ed2..49654736cc 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_ble_server/__init__.py b/esphome/components/zephyr_ble_server/__init__.py index 463b9c0887..2493bd9f95 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 223dbf7ac9..ef3daff916 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 ad89c1ac79..f5ab04809e 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 47913b34d7..0a99db8958 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 1f953100d9..0000000000 --- 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 d922ae372f..86cac665b4 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 2e1b09fb22..bb6c153752 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/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 92d238629a..3f14d0a62d 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,6 +1,7 @@ #include "zigbee_time_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME) #include "esphome/core/log.h" +#include "esphome/core/application.h" namespace esphome::zigbee { @@ -47,6 +48,7 @@ void ZigbeeTime::set_epoch_time(uint32_t epoch) { this->synchronize_epoch_(epoch); this->has_time_ = true; }); + App.wake_loop_threadsafe(); } void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 1fb8d1abe4..acf0321b12 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 fc229b4e95..305febf171 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 700267ef50..88f49ef3c1 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 cd094306f4..c6597ebfd6 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -49,7 +49,8 @@ void ZigbeeComponent::factory_reset() { void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) { if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + global_zigbee->set_timeout("zb_init", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); + App.wake_loop_threadsafe(); return; } if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { @@ -88,6 +89,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { global_zigbee->set_timeout("zb_init", 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); + App.wake_loop_threadsafe(); } } break; case EZB_BDB_SIGNAL_STEERING: { @@ -113,6 +115,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } + App.wake_loop_threadsafe(); } } break; case EZB_ZDO_SIGNAL_LEAVE: { @@ -151,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)", @@ -158,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) { @@ -177,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); } @@ -229,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() { @@ -269,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(), @@ -296,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(); @@ -338,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; @@ -380,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 c19fc3ad63..093ae95939 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 57fa3b2a00..103e901b13 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 0000000000..b8b3e97b4c --- /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 0793669955..e52513b740 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 6898068b44..305ab2224c 100644 --- a/esphome/components/zigbee/zigbee_helpers_esp32.h +++ b/esphome/components/zigbee/zigbee_helpers_esp32.h @@ -10,6 +10,7 @@ extern "C" { #include "esp_zigbee.h" +ezb_af_ep_desc_t esphome_zb_zha_default_ep_desc_create(uint8_t ep_id, uint16_t device_id, uint8_t power_source); ezb_err_t esphome_zb_cluster_add_or_update_attr(uint16_t cluster_id, ezb_zcl_cluster_desc_t cluster_desc, uint16_t attr_id, void *value_p); ezb_err_t esphome_zb_add_or_update_cluster(uint16_t cluster_id, ezb_af_ep_desc_t ep_desc, uint8_t role_mask); diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index b8bb0a2036..286c83b8f5 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -1,10 +1,10 @@ #include "zigbee_zephyr.h" #if defined(USE_ZIGBEE) && defined(USE_NRF52) #include "esphome/core/log.h" +#include "esphome/core/application.h" #include #include #include "esphome/core/hal.h" -#include "esphome/core/wake.h" extern "C" { #include @@ -120,7 +120,7 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; - esphome::wake_loop_threadsafe(); + App.wake_loop_threadsafe(); // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { @@ -138,6 +138,7 @@ void ZigbeeComponent::on_join_(bool factory_new) { ESP_LOGD(TAG, "Joined the network"); this->join_cb_.call(factory_new); }); + App.wake_loop_threadsafe(); } void ZigbeeComponent::on_start_() { @@ -145,6 +146,7 @@ void ZigbeeComponent::on_start_() { ESP_LOGD(TAG, "Started zigbee stack"); this->start_cb_.call(); }); + App.wake_loop_threadsafe(); } #ifdef USE_ZIGBEE_WIPE_ON_BOOT diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f47cf6bd40..3f863140e6 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 0508f456e4..cfe980f037 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 13ec744ce4..28a688f15a 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 2346c28cce..579da9a315 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import contextmanager, suppress +import copy from datetime import datetime from ipaddress import ( AddressValueError, @@ -420,6 +421,37 @@ class Required(vol.Required): self.visibility: Visibility | None = visibility +def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schema: + """Return a copy of ``schema`` with the given ``keys`` re-marked at ``visibility``. + + Lets a platform override the editor :class:`Visibility` of fields it + inherits from a shared schema builder — without that builder needing a + visibility parameter of its own. The canonical use is a ``template`` + platform promoting the value metadata its user is expected to define + (``device_class``, ``unit_of_measurement``, …) onto the main form: + + CONFIG_SCHEMA = cv.with_visibility( + sensor.sensor_schema(TemplateSensor), + cv.Visibility.UI, + CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT, + ) + + The original marker's key, default and validator are preserved; only the + visibility changes, and the input ``schema`` is left untouched. Raises if + a requested key is not present so typos fail at schema-build time. + """ + wanted = {str(k) for k in keys} + overrides = {} + for marker, validator in schema.schema.items(): + if str(marker) in wanted: + marker = copy.copy(marker) + marker.visibility = visibility + overrides[marker] = validator + if missing := wanted - {str(m) for m in overrides}: + raise ValueError(f"with_visibility: keys not in schema: {sorted(missing)}") + return schema.extend(overrides) + + class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" @@ -2322,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 0ec6a95869..f0c339416a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.9.1" +__version__ = "2026.10.0b1" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( @@ -23,12 +23,14 @@ class Toolchain(StrEnum): SDK_NRF = "sdk-nrf" # ESP8266: the Arduino core built directly (no PlatformIO) ARDUINO = "arduino" + # host: the system C/C++ compiler driven by ninja (no PlatformIO) + HOST = "host" # Toolchains that drive their build natively and never read platformio.ini. # SDK_NRF is absent on purpose: the zephyr backend keeps consuming # platformio_options. -NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO}) +NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST}) class Platform(StrEnum): @@ -352,7 +354,6 @@ CONF_DIRECTION = "direction" CONF_DIRECTION_COMMAND_TOPIC = "direction_command_topic" CONF_DIRECTION_OUTPUT = "direction_output" CONF_DIRECTION_STATE_TOPIC = "direction_state_topic" -CONF_DISABLE_CRC = "disable_crc" CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" @@ -1345,6 +1346,7 @@ DEVICE_CLASS_GARAGE = "garage" DEVICE_CLASS_GARAGE_DOOR = "garage_door" DEVICE_CLASS_GAS = "gas" DEVICE_CLASS_GATE = "gate" +DEVICE_CLASS_GLASS_BREAK = "glass_break" DEVICE_CLASS_HEAT = "heat" DEVICE_CLASS_HUMIDITY = "humidity" DEVICE_CLASS_IDENTIFY = "identify" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 5fcad90a81..1668263e33 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 38d3503c2c..2ddc97efb5 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -11,6 +11,19 @@ #include #include #include +#include +#if __has_include() +#include // ESP-IDF 6 +#include +#else +#include +#include +#endif +// Vendor flash drivers linked next to the generic one; sdkconfig defines each as 1 or not at all +#define ESPHOME_FLASH_VENDOR_DRIVERS \ + (CONFIG_SPI_FLASH_SUPPORT_ISSI_CHIP + CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP + CONFIG_SPI_FLASH_SUPPORT_GD_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_WINBOND_CHIP + CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP + CONFIG_SPI_FLASH_SUPPORT_TH_CHIP + \ + CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP) #endif #include "esphome/core/version.h" #include "esphome/core/hal.h" @@ -23,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). @@ -157,8 +170,25 @@ void Application::process_dump_config_() { esp_chip_info(&chip_info); ESP_LOGI(TAG, "ESP32 Chip: %s rev%d.%d, %d core(s)", ESPHOME_VARIANT, chip_info.revision / 100, chip_info.revision % 100, chip_info.cores); -#if defined(USE_ESP32_VARIANT_ESP32) && (!defined(USE_ESP32_MIN_CHIP_REVISION_SET) || !defined(USE_ESP32_SRAM1_AS_IRAM)) - static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; + [[maybe_unused]] static const char *const ESP32_ADVANCED_PATH = "under esp32 > framework > advanced"; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 0 + { + // Only the driver in use earns its IRAM; with several linked at least one is idle + const spi_flash_chip_t *flash_driver = esp_flash_default_chip->chip_drv; +#if ESPHOME_FLASH_VENDOR_DRIVERS > 1 + constexpr bool idle_driver = true; +#else + const bool idle_driver = flash_driver == &esp_flash_chip_generic; +#endif + if (idle_driver) { + const char *value = flash_driver->name; +#ifdef CONFIG_SPI_FLASH_SUPPORT_MXIC_OPI_CHIP + if (flash_driver == &esp_flash_chip_mxic_opi) + value = "mxic_opi"; +#endif + ESP_LOGW(TAG, "Set flash_chip: %s %s to save IRAM", value, ESP32_ADVANCED_PATH); + } + } #endif #if defined(USE_ESP32_VARIANT_ESP32) && !defined(USE_ESP32_MIN_CHIP_REVISION_SET) { diff --git a/esphome/core/application.h b/esphome/core/application.h index 2836587dfd..2f399cbdcd 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: @@ -528,7 +529,7 @@ class Application { // 1-byte members (grouped together to minimize padding) uint8_t app_state_{0}; - bool name_add_mac_suffix_; + bool name_add_mac_suffix_{false}; bool in_loop_{false}; volatile bool has_pending_enable_loop_requests_{false}; @@ -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 ea522a4d2d..f5921168e0 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: @@ -491,7 +550,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 +558,7 @@ template class Action { this->stop_next_(); } /// Check if this or any of the following actions are currently running. - virtual bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } + bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } /// The total number of actions that are currently running in this plus any of /// the following actions in the chain. @@ -608,7 +667,9 @@ template class ActionList { template class Automation { public: /// Default constructor for use with TriggerForwarder (no Trigger object needed). - Automation() = default; + // User provided, not "= default": `new(p) Automation()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr Automation() noexcept {} explicit Automation(Trigger *trigger) { trigger->set_automation_parent(this); } void add_action(Action *action) { this->actions_.add_action(action); } diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 276b8aa972..8762cbe711 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include namespace esphome { @@ -180,7 +181,9 @@ class ProjectUpdateTrigger : public Trigger, public Component { template class DelayAction : public Action { public: - explicit DelayAction() = default; + // User provided, not "= default": `new(p) DelayAction()` would zero-fill .bss that is already zero. + // constexpr and noexcept keep the rest of the implicit constructor's contract. + constexpr explicit DelayAction() noexcept {} TEMPLATABLE_VALUE(uint32_t, delay) @@ -250,6 +253,21 @@ template class StatelessLambdaAction : public Action { void (*f_)(Ts...); }; +/// Runs one codegen-generated function that has the parent and every field baked in. The +/// function is a template argument, so play() calls it directly and the object is just the +/// Action base. Args pass by const reference so a std::string arg is never copied; +/// StatelessLambdaAction keeps by-value parameters because user `lambda:` code owns them. +template class ApplyAction final : public Action { + public: + void play(const Ts &...x) override { Fn(x...); } +}; + +/// Condition counterpart of ApplyAction: one codegen-generated predicate with the parent baked in. +template class ApplyCondition final : public Condition { + public: + bool check(const Ts &...x) override { return Fn(x...); } +}; + /// Simple continuation action that calls play_next_ on a parent action. /// Used internally by IfAction, WhileAction, RepeatAction, etc. to chain actions. /// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index edbc771472..ba8a594340 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -6,18 +6,13 @@ namespace esphome { constinit const Color Color::BLACK(0, 0, 0, 0); constinit const Color Color::WHITE(255, 255, 255, 255); -Color Color::gradient(const Color &to_color, uint8_t amnt) { - uint8_t inv = 255 - amnt; - Color new_color; - new_color.r = (uint16_t(this->r) * inv + uint16_t(to_color.r) * amnt) / 255; - new_color.g = (uint16_t(this->g) * inv + uint16_t(to_color.g) * amnt) / 255; - new_color.b = (uint16_t(this->b) * inv + uint16_t(to_color.b) * amnt) / 255; - new_color.w = (uint16_t(this->w) * inv + uint16_t(to_color.w) * amnt) / 255; - return new_color; +Color Color::gradient(const Color &to_color, uint8_t amnt) const { + return Color(blend_channel(this->r, to_color.r, amnt), blend_channel(this->g, to_color.g, amnt), + blend_channel(this->b, to_color.b, amnt), blend_channel(this->w, to_color.w, amnt)); } -Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); } +Color Color::fade_to_white(uint8_t amnt) const { return this->gradient(Color::WHITE, amnt); } -Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); } +Color Color::fade_to_black(uint8_t amnt) const { return this->gradient(Color::BLACK, amnt); } } // namespace esphome diff --git a/esphome/core/color.h b/esphome/core/color.h index 442470623d..c7fd522e1a 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -174,9 +174,15 @@ struct Color { uint8_t((uint16_t(b) * 255U / max_rgb)), w); } - Color gradient(const Color &to_color, uint8_t amnt); - Color fade_to_white(uint8_t amnt); - Color fade_to_black(uint8_t amnt); + /// One channel of gradient(): from at amnt 0 to to at amnt 255. Inline so a + /// per pixel loop can blend without a call; gradient() itself stays out of + /// line so the light effects and fade_to_*() share one copy. + static inline uint8_t blend_channel(uint8_t from, uint8_t to, uint8_t amnt) ESPHOME_ALWAYS_INLINE { + return (uint16_t(from) * (255 - amnt) + uint16_t(to) * amnt) / 255; + } + Color gradient(const Color &to_color, uint8_t amnt) const; + Color fade_to_white(uint8_t amnt) const; + Color fade_to_black(uint8_t amnt) const; Color lighten(uint8_t delta) { return *this + delta; } Color darken(uint8_t delta) { return *this - delta; } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 41dd32ea66..0aa2dc21a2 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 fac09e9e14..3c11d4a880 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 67a7b5210e..531a027647 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) @@ -717,9 +801,10 @@ async def to_code(config: ConfigType) -> None: cg.add_global(cg.RawExpression("using std::min")) cg.add_global(cg.RawExpression("using std::max")) - # Construct App via placement new — see application.cpp for storage details + # Construct App via placement new — see application.cpp for storage details. + # No parens: `Application()` would zero-fill storage that is already zero. cg.add_global(cg.RawStatement("#include ")) - cg.add(cg.RawExpression("new (&App) Application()")) + cg.add(cg.RawExpression("new (&App) Application")) name = config[CONF_NAME] friendly_name = config[CONF_FRIENDLY_NAME] name_add_mac_suffix = config[CONF_NAME_ADD_MAC_SUFFIX] @@ -754,7 +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) @@ -768,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 @@ -779,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) @@ -873,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, @@ -926,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 09975b465f..0000000000 --- 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 0000000000..fc6a03556d --- /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 907e0f923d..0000000000 --- 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 c6113116ff..8b1b6c7f9d 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 94f7648f29..12f61467d6 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -70,7 +70,6 @@ #define USE_ESP32_CAMERA_JPEG_CONVERSION #define USE_ESP32_HOSTED #define USE_ESP32_HOSTED_HTTP_UPDATE -#define USE_ESP32_IMPROV_STATE_CALLBACK #define USE_ESP_NOW_HOSTED #define USE_EVENT #define USE_FAN @@ -78,17 +77,24 @@ #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU +#define USE_HOERMANN_HCP_IDENTITY #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT #define USE_I2S_AUDIO_SPDIF_MODE #define USE_IMAGE +#define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF +#define USE_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,50 @@ #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define MK2PVROUTER_LISTENER_COUNT 1 +#define NFC_TAG_LISTENER_COUNT 1 +#define PN532_BINARY_SENSOR_COUNT 1 +#define PN532_ON_TAG_REMOVED_TRIGGER_COUNT 1 +#define PN532_ON_TAG_TRIGGER_COUNT 1 +#define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1 +#define PN71XX_ON_TAG_TRIGGER_COUNT 1 +#define REMOTE_BASE_DUMPER_COUNT 1 +#define REMOTE_BASE_LISTENER_COUNT 1 +#define USE_REMOTE_PROTOCOL_ABBWELCOME +#define USE_REMOTE_PROTOCOL_AEHA +#define USE_REMOTE_PROTOCOL_BEO4 +#define USE_REMOTE_PROTOCOL_BRENNENSTUHL +#define USE_REMOTE_PROTOCOL_BYRONSX +#define USE_REMOTE_PROTOCOL_CANALSAT +#define USE_REMOTE_PROTOCOL_COOLIX +#define USE_REMOTE_PROTOCOL_DISH +#define USE_REMOTE_PROTOCOL_DOOYA +#define USE_REMOTE_PROTOCOL_DRAYTON +#define USE_REMOTE_PROTOCOL_DYSON +#define USE_REMOTE_PROTOCOL_GOBOX +#define USE_REMOTE_PROTOCOL_HAIER +#define USE_REMOTE_PROTOCOL_JVC +#define USE_REMOTE_PROTOCOL_KEELOQ +#define USE_REMOTE_PROTOCOL_LG +#define USE_REMOTE_PROTOCOL_MAGIQUEST +#define USE_REMOTE_PROTOCOL_MIDEA +#define USE_REMOTE_PROTOCOL_MIRAGE +#define USE_REMOTE_PROTOCOL_NEC +#define USE_REMOTE_PROTOCOL_NEXA +#define USE_REMOTE_PROTOCOL_PANASONIC +#define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PRONTO +#define USE_REMOTE_PROTOCOL_RAW +#define USE_REMOTE_PROTOCOL_RC5 +#define USE_REMOTE_PROTOCOL_RC6 +#define USE_REMOTE_PROTOCOL_RC_SWITCH +#define USE_REMOTE_PROTOCOL_ROOMBA +#define USE_REMOTE_PROTOCOL_SAMSUNG +#define USE_REMOTE_PROTOCOL_SAMSUNG36 +#define USE_REMOTE_PROTOCOL_SONY +#define USE_REMOTE_PROTOCOL_SYMPHONY +#define USE_REMOTE_PROTOCOL_TOSHIBA_AC +#define USE_REMOTE_PROTOCOL_TOTO +#define SYSTA_BUS_LISTENER_COUNT 1 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER @@ -181,6 +231,7 @@ #define USE_SENSOR #define USE_SENSOR_FILTER #define USE_SERIAL_PROXY +#define USE_SERIAL_PROXY_TAP #define USE_SETUP_PRIORITY_OVERRIDE #define USE_STATUS_LED #define USE_STATUS_SENSOR @@ -205,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 @@ -216,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 @@ -225,12 +284,15 @@ #define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #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_SERIAL_NEXT_URL +#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 @@ -244,6 +306,7 @@ #define USE_RUNTIME_IMAGE_QOI #define USE_RUNTIME_STATS #define USE_OTA +#define USE_OTA_DEFLATE #define USE_OTA_ENCRYPTION #define USE_OTA_ENCRYPTION_PROVISIONED #define USE_OTA_ENCRYPTION_REQUIRED @@ -292,12 +355,15 @@ #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif +// Defined here so static analysis parses the slot and its call site. +#define USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER #define BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE 16 #endif // ESP32-specific feature flags #ifdef USE_ESP32 #define USE_ESP32_CRASH_HANDLER +#define USE_ESP32_VASPRINTF_STUB #define USE_ESP32_INTERNAL_GPIO #define USE_MQTT_IDF_ENQUEUE #define USE_ESPHOME_TASK_LOG_BUFFER @@ -335,6 +401,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 @@ -347,11 +414,11 @@ #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 -#define USE_IMPROV -#define USE_ESP32_IMPROV_NEXT_URL +#define USE_IMPROV_BLE #define USE_MICROPHONE #define USE_PSRAM #define USE_SENDSPIN @@ -359,9 +426,14 @@ #define USE_SENDSPIN_CONTROLLER #define USE_SENDSPIN_METADATA #define USE_SENDSPIN_PLAYER +#define USE_SENDSPIN_SWITCH #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS +#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 @@ -398,6 +470,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) @@ -420,14 +501,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 @@ -437,6 +521,13 @@ #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 #endif #if defined(USE_ESP32_VARIANT_ESP32S2) @@ -457,6 +548,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) @@ -492,7 +584,7 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 0, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 1) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -530,10 +622,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 @@ -559,6 +653,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 @@ -569,6 +664,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 @@ -584,7 +680,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 dc27c1e56a..cbb14a3730 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 54e2551cb4..4f44605025 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 2f3faf818a..1067ae07cc 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 433d2547b0..6130f7e27d 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 b1f24b25a3..b88a9d70e9 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(). @@ -242,8 +301,9 @@ template class StaticVector { size_t count_{0}; public: - // Default constructor - StaticVector() = default; + // User provided, not "= default": otherwise `StaticVector<...> x_{}` members + // value-initialize and memset data_, defeating the comment above. + constexpr StaticVector() noexcept {} // Iterator range constructor template StaticVector(InputIt first, InputIt last) { @@ -275,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) { @@ -2065,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 9fcddfeff6..2f1d568758 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 14d24412ef..145ba3a6f8 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 0000000000..fbb4eaf79f --- /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 d349418d02..54fc42dde1 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 afb323f78e..5acd2eec27 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 56fc83f12f..4911bdde74 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 4301108315..87629c2507 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 e2996abeda..10c55f70c6 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 2c7ec914c7..4c0ce28a5a 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 25076228d5..1b0a49acaf 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 54a7956163..4bca130e4a 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 8f90aa3411..aa3098ba65 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/coroutine.py b/esphome/coroutine.py index 3ce94cc979..8a82536227 100644 --- a/esphome/coroutine.py +++ b/esphome/coroutine.py @@ -45,6 +45,7 @@ the last `yield` expression defines what is returned. from __future__ import annotations from collections.abc import Awaitable, Callable, Generator, Iterator +import contextvars import enum import functools import heapq @@ -277,14 +278,22 @@ class _Task: id_number: int, iterator: Iterator[None], original_function: Any, + context: contextvars.Context, ): self.priority = priority self.id_number = id_number self.iterator = iterator self.original_function = original_function + self.context = context def with_priority(self, priority: float) -> _Task: - return _Task(priority, self.id_number, self.iterator, self.original_function) + return _Task( + priority, + self.id_number, + self.iterator, + self.original_function, + self.context, + ) @property def _cmp_tuple(self) -> tuple[float, int]: @@ -321,7 +330,10 @@ class FakeEventLoop: coro = coroutine(func) gen = coro(*args, **kwargs) prio = getattr(coro, "priority", 0.0) - task = _Task(prio, self._task_counter, gen, func) + # Each task gets its own copy of the current context, isolating any + # contextvars it sets from other tasks the scheduler interleaves it with + # (mirrors what asyncio.Task does internally). + task = _Task(prio, self._task_counter, gen, func, contextvars.copy_context()) self._task_counter += 1 heapq.heappush(self._pending_tasks, task) @@ -352,7 +364,7 @@ class FakeEventLoop: ) try: - next(task.iterator) + task.context.run(next, task.iterator) # Decrease priority over time, so that if this task is blocked # due to a dependency others will clear the dependency # This could be improved with a less naive approach diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index e6b8c0de42..267e86c2ed 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. @@ -1187,3 +1261,48 @@ class MockObjClass(MockObj): def __repr__(self): return f"MockObjClass<{str(self.base)}, parents={self._parents}>" + + +class StaticCastExpression(Expression): + __slots__ = ("type", "exp") + + def __init__(self, type: Any, exp: SafeExpType): + self.type = str(type) + self.exp = safe_exp(exp) + + def __str__(self): + return f"static_cast<{self.type}>({self.exp})" + + +def call_lambda(lamb: LambdaExpression) -> Expression: + """ + Given a lambda, either reduce to a simple expression or call it, possibly with parameters + from the surrounding context. + This is for use only with value-returning lambdas, used in places where the value of a lambda call is needed. + :param lamb: The LambdaExpression to call or reduce + :return: An Expression representing the result of calling the lambda or reducing it to a simple expression + """ + # Developer error if this is called with a lambda that doesn't have a return type + assert lamb.return_type is not None, "Lambda must have a return type to be called" + expr = lamb.content.strip() + # A lone `return ;` reduces to the expression; anything longer is called as is. + # A braced return such as `return {};` needs the lambda's return type, so it is called. + if ( + re.match(r"^return\b", expr) + and expr.endswith(";") + and expr.count(";") == 1 + and not expr[6:].lstrip().startswith("{") + ): + expr = RawExpression(expr[6:-1].strip()) + # Don't cast if the return type is a class + if isinstance(lamb.return_type, MockObjClass): + return expr + return StaticCastExpression(lamb.return_type, expr) + # If lambda has parameters, call it with their names + # Parameter names come from hardcoded component code (like "x", "it", "event") + # not from user input, so they're safe to use directly + if lamb.parameters and lamb.parameters.parameters: + return CallExpression( + lamb, *[MockObj(x.id) for x in lamb.parameters.parameters] + ) + return CallExpression(lamb) diff --git a/esphome/cpp_helpers.py b/esphome/cpp_helpers.py index 53b59cb124..fc44d27f47 100644 --- a/esphome/cpp_helpers.py +++ b/esphome/cpp_helpers.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Hashable from dataclasses import dataclass, field import logging @@ -142,9 +142,10 @@ _SLOT_COUNTER_DOMAIN = "slot_counter" @dataclass class _SlotCounterState: - """Per-run slot counter state: requested counts and already-emitted defines.""" + """Per-run slot counter state: requested counts per define and key, and + already-emitted defines.""" - counts: dict[str, int] = field(default_factory=dict) + counts: dict[str, dict[Hashable, int]] = field(default_factory=dict) emitted: set[str] = field(default_factory=set) @@ -156,11 +157,13 @@ def _get_slot_counter_state() -> _SlotCounterState: def get_slot_count(define: str) -> int: - """Number of slots requested so far for `define`.""" - return _get_slot_counter_state().counts.get(define, 0) + """Value `define` would be emitted with so far: the largest count requested + under any one key, which is the plain request count when no key is used.""" + counts = _get_slot_counter_state().counts.get(define) + return max(counts.values()) if counts else 0 -def slot_counter(define: str) -> Callable[[], None]: +def slot_counter(define: str) -> Callable[..., None]: """Create a request_slot function for codegen-sized storage. The pattern behind a StaticVector listener array: a consumer's to_code @@ -169,6 +172,11 @@ def slot_counter(define: str) -> Callable[[], None]: emitted with the requested count. No requests, no define: the guarded storage and its registration method compile out entirely. + When several objects each declare the storage at the same size (one list + per receiver, per hub, ...) the caller passes the owning object as `key` + and the define becomes the largest count any one key requested, not the + total. Requests without a key share one count. + The counts live in a table under CORE.data, which clears between runs. A request arriving after the define was already emitted raises instead of silently undercounting: the define would keep the stale smaller value and @@ -179,10 +187,10 @@ def slot_counter(define: str) -> Callable[[], None]: async def emit_job() -> None: state = _get_slot_counter_state() state.emitted.add(define) - # Scheduled only by the first request, so the count is always >= 1 here. - add_define(define, state.counts[define]) + # Scheduled only by the first request, so there is at least one count here. + add_define(define, max(state.counts[define].values())) - def request_slot() -> None: + def request_slot(key: Hashable = None) -> None: state = _get_slot_counter_state() if define in state.emitted: raise ValueError( @@ -190,10 +198,16 @@ def slot_counter(define: str) -> Callable[[], None]: f"define was emitted; request slots from to_code, not from a " f"job running after FINAL emission" ) - counts = state.counts - counts[define] = (count := counts.get(define, 0) + 1) - if count == 1: + counts = state.counts.get(define) + if counts is None: + counts = state.counts[define] = {} CORE.add_job(emit_job) + elif (key is None) != (None in counts): + # a keyed and an unkeyed request would compare buckets instead of adding up + raise ValueError( + f"slot_counter('{define}'): every request must use a key, or none of them" + ) + counts[key] = counts.get(key, 0) + 1 return request_slot diff --git a/esphome/cpp_types.py b/esphome/cpp_types.py index 45d6559b3f..04892f5fea 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 0000000000..624a07fa25 --- /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