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 fa42372ac8..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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 8b0c821503..1d071b3866 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -53,7 +53,7 @@ module.exports = async ({ github, context }) => { }); const currentLabels = currentLabelsData.map(label => label.name); const managedLabels = currentLabels.filter(label => - label.startsWith('component: ') || MANAGED_LABELS.includes(label) + label.startsWith('component: ') || label.startsWith('platform: ') || MANAGED_LABELS.includes(label) ); // Check for mega-PR early - if present, skip most automatic labeling diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index e9e848da6f..8b1db61f46 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -42,8 +42,8 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`; message += - `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` + - `this PR is on the large side `; + `Hey @${prAuthor}, thanks for the contribution. This PR is on the ` + + `large side `; if (tooManyLabels && tooManyChanges) { message += @@ -57,16 +57,31 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo, } message += `, which makes it harder for maintainers to review.\n\n`; + let condition; + if (tooManyLabels && tooManyChanges) { + condition = + `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests) and touches ` + + `at most ${MAX_LABELS} components/areas`; + } else if (tooManyLabels) { + condition = `touches at most ${MAX_LABELS} components/areas`; + } else { + condition = `is at most ${TOO_BIG_THRESHOLD} lines (excluding tests)`; + } message += - `Smaller, focused PRs tend to be reviewed much faster since they ` + - `fit into the short gaps between other maintainer work; large ones ` + - `often have to wait for a rare long uninterrupted block of time. ` + - `If you can break this up into smaller pieces that can be reviewed ` + - `independently, it will almost certainly land faster overall.\n\n`; + `**This PR will not be reviewed until it ${condition}.** We know ` + + `that is not what you want to hear after this much work, and we want ` + + `to be upfront so you do not wait for a review that is not coming: ` + + `please split the change. The label is removed automatically once ` + + `the PR ${condition}.\n\n`; message += - `Before putting more time in, it's also worth popping into ` + - `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` + - `you scope things and flag anything already in flight.\n\n`; + `There is no exception you can request. Rarely, a maintainer agrees in ` + + `\`#devs\` on [Discord](https://esphome.io/chat) to review a change that ` + + `genuinely cannot be split as one piece and adds the \`mega-pr\` label, ` + + `but that is rare enough that you should plan on splitting rather than ` + + `wait for it. Smaller, focused PRs are picked up much faster because ` + + `they fit into the short gaps between other maintainer work. For a new ` + + `component, the first PR brings the hub and a single platform (for ` + + `example \`sensor\`); each further platform is its own PR on top of it.\n\n`; message += `For more details (including how to split the work up), see: ` + `https://developers.esphome.io/contributing/submitting-your-work/` + @@ -214,6 +229,7 @@ async function handleMaintainerAccessComment(github, context, maintainerAccess) } module.exports = { + generateReviewMessages, handleReviews, handleMaintainerAccessComment }; diff --git a/.github/scripts/auto-label-pr/tests/reviews.test.js b/.github/scripts/auto-label-pr/tests/reviews.test.js new file mode 100644 index 0000000000..2390d2a073 --- /dev/null +++ b/.github/scripts/auto-label-pr/tests/reviews.test.js @@ -0,0 +1,38 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { generateReviewMessages } = require('../reviews'); + +const MAX_LABELS = 15; +const TOO_BIG_THRESHOLD = 1000; + +function tooBigMessage(labelCount, additions) { + const files = [{ filename: 'esphome/components/a/a.cpp', additions, deletions: 0 }]; + const messages = generateReviewMessages(['too-big'], labelCount, null, files, additions, 0, 'someone', MAX_LABELS, TOO_BIG_THRESHOLD); + assert.equal(messages.length, 1); + return messages[0]; +} + +describe('too-big review message', () => { + it('names the line cap when the PR is too long', () => { + const message = tooBigMessage(3, 1500); + assert.match(message, /will not be reviewed until it is at most 1000 lines \(excluding tests\)\./); + assert.match(message, /removed automatically once the PR is at most 1000 lines/); + assert.doesNotMatch(message, /components\/areas\./); + }); + + it('names the area cap when the PR touches too many areas but is short', () => { + const message = tooBigMessage(20, 200); + assert.match(message, /will not be reviewed until it touches at most 15 components\/areas\./); + assert.doesNotMatch(message, /until it is at most 1000 lines/); + }); + + it('names both caps when both are exceeded', () => { + const message = tooBigMessage(20, 1500); + assert.match(message, /is at most 1000 lines \(excluding tests\) and touches at most 15 components\/areas\./); + }); + + it('tells new-component authors to bring one platform first', () => { + assert.match(tooBigMessage(3, 1500), /hub and a single platform/); + assert.match(tooBigMessage(3, 1500), /mega-pr/); + }); +}); diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index c4c1ab072f..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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5ef51be02c..5bcdb22cb8 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -19,6 +19,7 @@ on: - "esphome/core/**" - "esphome/writer.py" - "esphome/build_gen/**" + - "esphome/build_helpers/**" - "esphome/espidf/**" - "esphome/platformio/**" - "esphome/components/bk72xx/**" @@ -195,6 +196,7 @@ jobs: # the default. id: - esp8266-arduino + - esp8266-arduino-native - esp32-arduino-platformio - esp32-arduino-esp-idf - esp32-idf-platformio @@ -217,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 08cd852f02..285d6869e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -102,6 +102,8 @@ jobs: device-builder: ${{ steps.determine.outputs.device-builder }} esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} + esp8266-native: ${{ steps.determine.outputs.esp8266-native }} + esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-components }} changed-components: ${{ steps.determine.outputs.changed-components }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} @@ -165,6 +167,8 @@ jobs: echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT + echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT + echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT @@ -183,6 +187,33 @@ jobs: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} + seed-esp8266-native-cache: + name: Seed the esp8266 native toolchain cache + runs-on: ubuntu-24.04 + needs: + - common + # PR-branch cache saves are invisible to other PRs, so dev pushes seed + # the shared entry the component matrix, the memory impact jobs and + # test-esp8266-native restore. Only dev: the composite action saves + # nowhere else, so a beta/release push would download the toolchain and + # discard it. + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + timeout-minutes: 15 + steps: + - name: Check out code from GitHub + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Restore Python + uses: ./.github/actions/restore-python + with: + python-version: ${{ env.DEFAULT_PYTHON }} + cache-key: ${{ needs.common.outputs.cache-key }} + - name: Cache the native toolchain + uses: ./.github/actions/cache-arduino8266 + - name: Install the native toolchain + run: | + . venv/bin/activate + python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)" + ci-custom: name: Run script/ci-custom runs-on: ubuntu-24.04 @@ -255,7 +286,7 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - name: Run prek - uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 + uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1 with: prek-version: ${{ steps.prek.outputs.version }} # This job only runs on pull requests, so nothing ever populates @@ -268,7 +299,7 @@ jobs: # Pushes any fixes the hooks made back to the pull request. This step # must keep its default name: the GitHub App that performs the push # locates the workflow run by that name. - - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 + - uses: pre-commit-ci/lite-action@062bca0919bc9d6e66755cc05074b70c77e111fc # v1.2.0 if: always() with: msg: apply automatic formatting fixes @@ -369,14 +400,9 @@ jobs: matrix: bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} env: - # What the cache steps persist; libdeps is excluded (keyed per xdist - # worker and env, it never crosses runs). - INTEGRATION_PIO_CACHE_PATH: | - ~/.esphome-integration-tests/platformio/platforms - ~/.esphome-integration-tests/platformio/packages - ~/.esphome-integration-tests/platformio/appstate.json - ~/.esphome-integration-tests/platformio/.cache - ~/.esphome-integration-tests/platformio/.esphome.pio.stamp.json + # Registry libraries (noise-c, libsodium, ArduinoJson, lvgl) the host builds + # download, shared per xdist worker by tests/integration/conftest.py + INTEGRATION_LIBRARY_CACHE_PATH: ~/.esphome-integration-tests/pio_components steps: - name: Check out code from GitHub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -396,14 +422,13 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - - name: Restore integration PlatformIO cache - # Native platform + toolchain installed by shared_platformio_cache in - # tests/integration/conftest.py; a miss self-heals, so no restore-keys. - id: pio-cache + - name: Restore integration library cache + # A miss or a changed pin self-heals with a download, so no restore-keys + id: library-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: integration-pio-v1-${{ runner.os }}-py${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements.txt', 'tests/integration/fixtures/cache_init.yaml', 'esphome/components/host/__init__.py') }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: integration-libraries-v1-${{ runner.os }}-${{ hashFiles('esphome/components/json/__init__.py', 'esphome/components/noise/__init__.py', 'esphome/components/lvgl/__init__.py', 'esphome/components/improv_base/__init__.py') }} - name: Restore Python virtual environment id: cache-venv uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -413,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -471,16 +496,16 @@ jobs: # A full cron period of margin for the weekly refresh retention-days: 14 - name: Print ccache statistics - # esphome stores the PlatformIO ccache under the machine-global cache - # dir (see _ccache_env() in esphome/platformio/toolchain.py). - run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s - - name: Save integration PlatformIO cache + # esphome stores the host build's ccache under the machine-global + # cache dir (see get_build_env() in esphome/host/toolchain.py). + run: CCACHE_DIR="$HOME/.cache/esphome/host/ccache" ccache -s + - name: Save integration library cache # Bucket 0 only; the others would race the same immutable key. - if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.pio-cache.outputs.cache-hit != 'true' + if: success() && (github.ref == 'refs/heads/dev' || contains(github.event.pull_request.labels.*.name, 'ci-cache-write')) && strategy.job-index == 0 && steps.library-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ env.INTEGRATION_PIO_CACHE_PATH }} - key: ${{ steps.pio-cache.outputs.cache-primary-key }} + path: ${{ env.INTEGRATION_LIBRARY_CACHE_PATH }} + key: ${{ steps.library-cache.outputs.cache-primary-key }} import-time: name: Check import esphome.__main__ time @@ -601,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1 + uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 with: run: | . venv/bin/activate @@ -868,14 +893,20 @@ jobs: matrix: include: - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 1/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 1 + name: Run script/clang-tidy for ESP32 IDF 1/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 1 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 2/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 2 + name: Run script/clang-tidy for ESP32 IDF 2/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 2 - id: clang-tidy - name: Run script/clang-tidy for ESP32 IDF 3/3 - options: --environment esp32-idf-tidy --split-num 3 --split-at 3 + name: Run script/clang-tidy for ESP32 IDF 3/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 3 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 IDF 4/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 4 + - id: clang-tidy + name: Run script/clang-tidy for ESP32 IDF 5/5 + options: --environment esp32-idf-tidy --split-num 5 --split-at 5 steps: - name: Check out code from GitHub @@ -1097,6 +1128,11 @@ jobs: uses: ./.github/actions/cache-sdk-nrf with: restore-only: true + - name: Cache the native ESP8266 toolchain + # Only batches whose test platforms include esp8266; never saves + # here, it reuses the install the dev seed job cached. + if: matrix.batch.needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Validate and compile components with intelligent grouping run: | . venv/bin/activate @@ -1195,9 +1231,28 @@ jobs: if [ -n "$compile_csv" ]; then # Run compilation with grouping and isolation - python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" + # The bootloader has no ESPHome code and these builds never + # flash; the check_idf_py batch keeps the full build so the + # native sub-build and the equivalence check stay covered. + skip_flag="--skip-bootloader" + if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then + skip_flag="" + fi + python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" $skip_flag + if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then + # The real idf.py must find nothing to configure or build in a + # tree built above; catches drift on ESP-IDF bumps. + echo "Checking the native ESP-IDF build matches idf.py" + python3 script/check_idf_py_equivalence.py + fi else echo "All components in this batch are validate-only -- skipping compile stage." + if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then + # determine-jobs and this step disagree on what compiles; fail + # rather than let the check run nowhere. + echo "::error::This batch was picked for the idf.py check but compiled nothing" + exit 1 + fi fi - name: Print ccache statistics @@ -1237,7 +1292,7 @@ jobs: # compile validates config first, so a separate config pass is # redundant for this smoke test. ESP-IDF framework via PlatformIO: - python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio + python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests echo "" echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..." @@ -1246,6 +1301,125 @@ 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 + + test-esp8266-native-windows: + name: Compile esp8266 with the native toolchain on Windows (ccache ${{ matrix.ccache }}) + runs-on: windows-latest + needs: + - common + - determine-jobs + if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true' + # A hung compiler must fail the job, not hold the runner for six hours. + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + # Both ways the build runs on Windows: ESPHome Desktop bundles + # ccache and puts it on PATH, a pip install has none. + ccache: ["1", "0"] + env: + ESPHOME_CCACHE_ENABLE: ${{ matrix.ccache }} + # The test script prints characters the cp1252 console cannot encode + PYTHONUTF8: "1" + # The exact static build ESPHome Desktop ships + CCACHE_URL: https://github.com/ccache/ccache/releases/download/v4.14/ccache-4.14-windows-x86_64.zip + CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f + 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: Install ccache + if: matrix.ccache == '1' + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP "ccache" + New-Item -ItemType Directory -Force $dir | Out-Null + $zip = Join-Path $dir "ccache.zip" + Invoke-WebRequest -Uri $env:CCACHE_URL -OutFile $zip + $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() + if ($hash -ne $env:CCACHE_SHA256) { + Write-Host "::error::ccache archive sha256 $hash does not match $env:CCACHE_SHA256" + exit 1 + } + Expand-Archive -Path $zip -DestinationPath $dir + $bin = Get-ChildItem -Path $dir -Recurse -Filter ccache.exe | Select-Object -First 1 + Add-Content -Path $env:GITHUB_PATH -Value $bin.DirectoryName + & $bin.FullName --version + + - name: Compile + shell: pwsh + # One component test: web_server brings wifi, api and the async web + # server libraries, the shape of a typical ESP8266 device + run: | + . .\venv\Scripts\Activate.ps1 + $arguments = "script/test_build_components.py", "-e", "compile", "-t", "esp8266-ard", "-c", "web_server", "--base-only", "--toolchain", "arduino", "--fail-on-no-tests" + $p = Start-Process -FilePath python -ArgumentList $arguments -NoNewWindow -PassThru + # Reading the handle keeps ExitCode available after the process exits + $null = $p.Handle + if (-not $p.WaitForExit(25 * 60 * 1000)) { + Write-Host "::error::The compile did not finish in 25 minutes; processes still running:" + Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -match 'xtensa|ccache|ninja|esphome' } | + Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine | + Format-List | Out-String -Width 4096 | Write-Host + Get-Process | + Where-Object { $_.Name -match 'cc1|ccache|ninja|xtensa|python' } | + Select-Object Id, Name, CPU, WorkingSet64 | + Format-Table | Out-String | Write-Host + taskkill /PID $p.Id /T /F + exit 1 + } + exit $p.ExitCode + + - name: Show ccache statistics + # Also after a failed or killed compile: the counters say whether + # ccache was ever reached + if: always() && matrix.ccache == '1' + shell: pwsh + # The build keeps its store under the esphome tools cache + run: | + $env:CCACHE_DIR = Join-Path $env:LOCALAPPDATA "esphome\Cache\arduino8266\ccache" + ccache -s + device-builder: name: Test downstream esphome/device-builder runs-on: ubuntu-24.04 @@ -1274,7 +1448,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1333,31 +1507,27 @@ jobs: echo "::warning::ci_memory_impact_extract.py not found on target branch, skipping memory impact analysis" fi - # Check if test files exist on the target branch for the requested - # components and platform. When a PR adds new test files for a platform, - # the target branch won't have them yet, so skip instead of failing. - # This check must be done here (not in determine-jobs.py) because - # determine-jobs runs on the PR branch and cannot see what the target - # branch has. + # The build below uses --base-only, so skip unless the target branch has + # test..yaml for every requested component (test-*.yaml + # variants alone compile nothing, and a partial set would compare a + # smaller target build against the PR build). Checked here, not in + # determine-jobs.py, because determine-jobs runs on the PR branch and + # cannot see the target branch. - name: Check for test files on target branch id: check-tests if: steps.check-script.outputs.skip != 'true' run: | components='${{ toJSON(fromJSON(needs.determine-jobs.outputs.memory_impact).components) }}' platform="${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}" - found=false + missing="" for component in $(echo "$components" | jq -r '.[]'); do - # Check for test files matching the platform (test.platform.yaml or test-*.platform.yaml) - for f in tests/components/${component}/test*.${platform}.yaml; do - if [ -f "$f" ]; then - found=true - break 2 - fi - done + if [ ! -f "tests/components/${component}/test.${platform}.yaml" ]; then + missing="${missing} ${component}" + fi done - if [ "$found" = false ]; then + if [ -n "$missing" ]; then echo "skip=true" >> $GITHUB_OUTPUT - echo "::warning::No test files found on target branch for platform ${platform}, skipping memory impact analysis" + echo "::warning::No test.${platform}.yaml on target branch for${missing}, skipping memory impact analysis" else echo "skip=false" >> $GITHUB_OUTPUT fi @@ -1415,12 +1585,9 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.platformio - key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} + - name: Cache the native ESP8266 toolchain + if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' @@ -1505,11 +1672,9 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - name: Cache platformio - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.platformio - key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} + - name: Cache the native ESP8266 toolchain + if: fromJSON(needs.determine-jobs.outputs.memory_impact).needs_arduino8266 + uses: ./.github/actions/cache-arduino8266 - name: Build, compile, and analyze memory id: extract run: | @@ -1608,6 +1773,7 @@ jobs: needs: - common - seed-apt-cache + - seed-esp8266-native-cache - determine-jobs - ci-custom - pylint @@ -1623,6 +1789,8 @@ jobs: - clang-tidy-esp32-variants - test-build-components-split - test-esp32-platformio + - test-esp8266-native + - test-esp8266-native-windows - device-builder - memory-impact-target-branch - memory-impact-pr-branch diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index efa6ccf514..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@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + 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@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 + 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/stale.yml b/.github/workflows/stale.yml index 43b7e6663d..7507eb4a42 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,16 +16,18 @@ 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@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main + uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main secrets: ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} with: # Live only on dev: a workflow_dispatch from any other branch is a dry run dry-run: ${{ github.ref != 'refs/heads/dev' }} days-before-stale: 90 + days-before-stale-conflicting: 30 days-before-close: 7 stale-label: stale exempt-label: not-stale + conflict-label: needs-rebase ignored-users: esphbot,codecov-commenter stale-pr-message: > There hasn't been any activity on this pull request recently. This @@ -36,6 +38,18 @@ jobs: 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! + conflict-pr-message: > + This pull request has merge conflicts. Please merge the latest dev + branch into your branch to resolve them. + stale-conflicting-pr-message: > + The merge conflicts on this pull request have not been resolved for + 30 days, so it has been marked as stale and will be closed if they + are not resolved within 7 days. + + Please merge the latest dev branch into your branch. Once the pull + request is mergeable again the stale label is removed. + Thank you for your contribution! stale-issue-message: > There hasn't been any activity on this issue recently. Due to the 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 index 5599691ed1..73a3f7dbaa 100644 --- a/.github/workflows/sync-dependency-versions.yml +++ b/.github/workflows/sync-dependency-versions.yml @@ -15,6 +15,8 @@ on: # base branch checkout, so fork code never runs with the token. pull_request_target: types: [opened, synchronize, reopened] + branches: + - dev paths: - requirements_dev.txt - requirements_test.txt diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 84d5e229d9..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@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a024185768..727e8e2f9a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.8 + rev: v0.16.10 hooks: # Run the linter. - id: ruff @@ -18,7 +18,7 @@ repos: # Run the formatter. - id: ruff-format - repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 + rev: 7.4.1 hooks: - id: flake8 additional_dependencies: diff --git a/AGENTS.md b/AGENTS.md index 113cfe1b2a..5031c4a457 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ file does, and it is the authority when they disagree. The most useful starting - Function-local constants: `lower_snake_case` - Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Favor descriptive names over abbreviations + - Device components are named brand plus model (`siemens_ira211`), without a protocol-family prefix - Enumerator names: prefix every value of an `enum class` with the enum name converted to `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with @@ -177,6 +178,15 @@ file does, and it is the authority when they disagree. The most useful starting Pick the primitive by cadence: under 250 ms use a gated `loop()`; 500 ms and above use `set_interval`. Full reasoning, including why `set_interval` costs more below 500 ms: https://developers.esphome.io/architecture/components/advanced/#quick-rule-of-thumb + * **Repeating work:** use `set_interval`, never a `set_timeout` that re-arms itself; every re-arm + rebuilds the callback and re-queues a scheduler item, and the period drifts. + * **Scheduler ids:** name a timer only when it must be cancelled or replaced, and use a + `static constexpr uint32_t` id, never a string. Ids are per component instance and cannot clash with other + components, so number them from 0 and keep all of a component's ids together in one place. + ```cpp + static constexpr uint32_t READ_TIMEOUT_ID = 0; + this->set_timeout(READ_TIMEOUT_ID, 50, [this]() { this->read_(); }); + ``` * **Don't override a default with the same value:** if a base class method already returns what you want, do not override it. `Component::get_setup_priority()` returns `setup_priority::DATA`, so a component that wants `DATA` should simply leave it alone. @@ -184,6 +194,33 @@ file does, and it is the authority when they disagree. The most useful starting // Bad - this is exactly what the base class already does float get_setup_priority() const override { return setup_priority::DATA; } ``` + * **Constant data lives in RAM on ESP8266** unless marked `PROGMEM`: `const` tables, class-scope + constant arrays, and in-class `std::array` initializers (a `.rodata` template plus a copy). Mark the + table `PROGMEM` and read it with `progmem_memcpy()` or `progmem_read_byte()` (`esphome/core/hal.h`); + for a table of structs copy the whole entry out and return the copy, never a pointer into flash. + On other platforms they are a plain `memcpy` and dereference, so no `#ifdef` is needed. Declare log tags with + `ESPHOME_LOG_TAG(TAG, "name")`, pass status text as `LOG_STR`, e.g. + `this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL))`, and give an action whose payload is a byte array + a `TEMPLATABLE_BYTES` field so a static code stays in flash. + * **Fixed sets of names** (status words, model names): a `PROGMEM_STRING_TABLE` (`esphome/core/progmem.h`), + logged with `get_log_str` and `LOG_STR_ARG`, copied out with `ESPHOME_strncpy_P` before `publish_state`. + A `dump_config` helper that picks a name returns `const LogString *` from `LOG_STR(...)`. + * **CRCs and bit tricks** come from `esphome/core/helpers.h` (`crc8`, `crc16`, `reverse_bits`, `encode_uint16`); + pick the helper's polynomial and bit-order arguments instead of writing a CRC loop. + * **An unset float is `NAN`**, not `optional`; the optional costs 4 to 8 bytes of padding per member. + * **`PollingComponent` first runs `update()`** after a random share of the interval; call `this->update()` at the + end of `setup()` when the entities need a state at boot. + * **A multi-step command sequence** leaves the step pending when the answer is malformed, so the existing + resend and give-up logic handles it; never advance on an answer that was only logged. + * **Entities that each watch a register field:** one class taking register, shift, mask and match from a Python + tuple table, in typed `StaticVector`s sized by `cg.slot_counter`; not a template per bit, not a virtual + listener base (a second vptr per entity). + * **A global feature define** says the code is compiled in, not that this instance uses it: keep the + per-instance check (`if (this->aux_device_ == ...)`) inside the `#ifdef`. + * **Failures after `setup()`:** `mark_failed()` is for `setup()` only. A runtime read or write failure + sets `status_set_warning()` and the next success clears it; writes skip work when `is_failed()`. + * **Fixed platform facts compile to nothing:** gate on `#ifdef USE_ESP8266` and friends, not on a + runtime check of something the build already knows. * **Logging string literals:** wrap literals passed as `%s` arguments in `LOG_STR_LITERAL()` so they can be stored in flash rather than RAM. ```cpp @@ -230,6 +267,10 @@ file does, and it is the authority when they disagree. The most useful starting ``` * **Component Metadata:** + - `DOMAIN`: Required in every component's `__init__.py`, set to the component's own name as a plain + string (e.g. `DOMAIN = "my_component"`). Other code refers to the component through it, e.g. + `from esphome.components.my_component import DOMAIN`. Keep it in alphabetical order with the other + metadata constants. CI (`lint_component_domain`) fails without it. - `DEPENDENCIES`: List of required components - `AUTO_LOAD`: Components to automatically load - `CONFLICTS_WITH`: Incompatible components @@ -243,6 +284,7 @@ file does, and it is the authority when they disagree. The most useful starting import esphome.config_validation as cv from esphome.const import CONF_KEY, CONF_ID + DOMAIN = "my_component" CONF_PARAM = "param" # A constant that does not yet exist in esphome/const.py my_component_ns = cg.esphome_ns.namespace("my_component") @@ -322,6 +364,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). @@ -365,6 +426,25 @@ file does, and it is the authority when they disagree. The most useful starting ) ``` + When the callback's parameters differ from the automation's arguments, or the trigger should only + fire for some values, pass `params`, `forward` and `when`; the helper then generates a capture-less + lambda (stored inline, nothing allocated). Name the parent with `automation.parent_ref(var)` and build + `forward` from `MockObj` calls, not f-strings of C++: + ```python + parent = automation.parent_ref(var) + index = cg.RawExpression("index") + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.StringRef, "x"), (cg.size_t, "i")], + conf, + params=[(cg.size_t, "index")], + forward=[cg.StringRef(parent.option_at(index)), index], + ) + ``` + `build_parent_callback_automation`, `build_trigger_callback`, `build_callback_automations` and + `build_trigger_automations` cover the other shapes; see their docstrings in `esphome/automation.py`. + **C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation): ```cpp class MyComponent : public Component { @@ -484,6 +564,7 @@ file does, and it is the authority when they disagree. The most useful starting `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`. + An intentionally empty body (an unused `register_dumper` builder, for example) gets a one-line docstring, not `pass`. * **Configuration Validation:** * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`. @@ -519,6 +600,15 @@ file does, and it is the authority when they disagree. The most useful starting * **Testing:** * **Python:** Run unit tests with `pytest`. * **C++:** Use `clang-tidy` for static analysis. + * **C++ unit tests:** gtests in `tests/components//*.cpp`, run with + `script/cpp_unit_test.py `; see `tests/components/README.md`. Set the loop clock in a test + with `LoopBlockingGuard{nullptr, nullptr, now_ms}`; the runner resets it before every test. + * **Integration tests:** `tests/integration/` runs real firmware on the host. Serial components prove + timeouts and recovery with the `uart_mock` fixture (`tests/integration/test_uart_mock_*.py`). Its + `responses` match on content every time they see it; to answer a repeated request differently, use an + `on_tx` lambda with a static counter and `inject_to_rx_buffer` (see `uart_mock_ld2412_requery_after_giveup.yaml`). + * **Keep build-test variants few:** one `test..yaml` per platform the code actually differs + on. A variant per board for platform-identical code only adds CI time. * **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows: ``` tests/ @@ -565,6 +655,7 @@ file does, and it is the authority when they disagree. The most useful starting name: My Sensor ``` Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time. + `!extend` and `!remove` in a `test.*.yaml` also make the component ungroupable; add a standalone entity with its own id instead. * **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use: ```bash @@ -737,7 +828,9 @@ file does, and it is the authority when they disagree. The most useful starting 6. **Avoid `std::deque`:** It allocates in 512-byte blocks regardless of element size, guaranteeing at least 512 bytes of RAM usage immediately. This is a major source of crashes on memory-constrained devices. - 7. **Detection:** Look for these patterns in compiler output: + 7. **Never use `new (std::nothrow)`:** On ESP-IDF exceptions are disabled, so a failed nothrow allocation aborts instead of returning `nullptr`. Use `RAMAllocator` from `esphome/core/helpers.h`; CI rejects `std::nothrow`. + + 8. **Detection:** Look for these patterns in compiler output: - Large code sections with STL symbols (vector, map, set) - `alloc`, `realloc`, `dealloc` in symbol names - `_M_realloc_insert`, `_M_default_append` (vector reallocation) diff --git a/CODEOWNERS b/CODEOWNERS index 5c7063f0a5..075b8113d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -39,9 +39,12 @@ esphome/components/am43/sensor/* @buxtronix esphome/components/analog_threshold/* @ianchi esphome/components/animation/* @syndlex esphome/components/anova/* @buxtronix +esphome/components/apc1/* @ademuri esphome/components/apds9306/* @aodrenah +esphome/components/apds9930/* @iyesin esphome/components/api/* @esphome/core esphome/components/aqi/* @freekode @jasstrong @ximex +esphome/components/aranet/* @patrickod esphome/components/as5600/* @ammmze esphome/components/as5600/sensor/* @ammmze esphome/components/as7341/* @mrgnr @@ -65,6 +68,7 @@ esphome/components/bedjet/climate/* @jhansche esphome/components/bedjet/fan/* @jhansche esphome/components/bedjet/sensor/* @javawizard @jhansche esphome/components/beken_spi_led_strip/* @Mat931 +esphome/components/bh1745/* @latonita esphome/components/bh1750/* @OttoWinter esphome/components/bh1900nux/* @B48D81EFCC esphome/components/binary_sensor/* @esphome/core @@ -100,6 +104,8 @@ esphome/components/bmp581_i2c/* @danielkent-net @kahrendt esphome/components/bmp581_spi/* @danielkent-net @kahrendt esphome/components/bp1658cj/* @Cossid esphome/components/bp5758d/* @Cossid +esphome/components/bq25186/* @Rapsssito +esphome/components/bq27220/* @rggammon esphome/components/bridge/* @kbx81 esphome/components/bthome_mithermometer/* @nagyrobi esphome/components/button/* @esphome/core @@ -125,6 +131,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 @@ -155,8 +162,10 @@ esphome/components/ds1307/* @badbadc0ffee esphome/components/ds1603l/* @JakeLC15 esphome/components/ds2484/* @mrk-its esphome/components/ds248x/* @tomwellnitz +esphome/components/ds3231/* @linkedupbits esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov +esphome/components/ecocomfort2/* @gledian esphome/components/ee895/* @Stock-M esphome/components/ektf2232/touchscreen/* @jesserockz esphome/components/emc2101/* @ellull @@ -182,17 +191,22 @@ 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_power_management/* @rwrozelle 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/esphome/ota/* @esphome/core 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 +esphome/components/ezsp_proxy_tap/* @kbx81 @puddly esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi @@ -244,8 +258,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 @@ -257,6 +274,7 @@ esphome/components/http_request/ota/* @oarcher esphome/components/http_request/update/* @jesserockz esphome/components/htu31d/* @betterengineering esphome/components/hub75/* @stuartparmenter +esphome/components/husb238/* @latonita esphome/components/hydreon_rgxx/* @functionpointer esphome/components/hyt271/* @Philippe12 esphome/components/i2c/* @esphome/core @@ -264,6 +282,7 @@ esphome/components/i2c_device/* @gabest11 esphome/components/i2s_audio/* @jesserockz esphome/components/i2s_audio/microphone/* @jesserockz esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt +esphome/components/i2s_clockless_led_strip/* @j9brown esphome/components/iaqcore/* @yozik04 esphome/components/icnt86/* @danepowell esphome/components/ili9xxx/* @clydebarrow @nielsnl68 @@ -281,6 +300,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 @@ -292,9 +312,11 @@ esphome/components/kuntze/* @ssieb esphome/components/lc709203f/* @ilikecake esphome/components/lcd_menu/* @numo68 esphome/components/ld2410/* @regevbr @sebcaps +esphome/components/ld2410s/* @NovakIrs esphome/components/ld2412/* @Rihan9 esphome/components/ld2420/* @descipher esphome/components/ld2450/* @hareeshmu +esphome/components/ld2460/* @jesusvallejo esphome/components/ld24xx/* @kbx81 esphome/components/ld6002b/* @hepter esphome/components/ledc/* @OttoWinter @@ -303,6 +325,10 @@ esphome/components/libretiny_pwm/* @kuba2k2 esphome/components/light/* @esphome/core esphome/components/lightwaverf/* @max246 esphome/components/lilygo_t5_47/touchscreen/* @jesserockz +esphome/components/lis2dw12/* @latonita +esphome/components/lis2dw12_i2c/* @latonita +esphome/components/lis2dw12_spi/* @latonita +esphome/components/lis3dh/* @zebble esphome/components/lm75b/* @beormund esphome/components/ln882h_ble/* @Bl00d-B0b esphome/components/ln882h_ble_tracker/* @Bl00d-B0b @@ -310,6 +336,7 @@ esphome/components/ln882x/* @lamauny esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/logger/select/* @clydebarrow +esphome/components/lp5562/* @DennisGaida esphome/components/lps22/* @nagisa esphome/components/lsm6ds/* @clydebarrow esphome/components/ltr390/* @latonita @sjtrny @@ -320,7 +347,9 @@ esphome/components/m5stack_8angle/* @rnauber esphome/components/mapping/* @clydebarrow esphome/components/matrix_keypad/* @ssieb esphome/components/max17043/* @blacknell +esphome/components/max17048/* @B48D81EFCC esphome/components/max31865/* @DAVe3283 +esphome/components/max31888/* @JiriPrchal esphome/components/max44009/* @berfenger esphome/components/max6956/* @looping40 esphome/components/max7219digit/* @rspaargaren @@ -339,7 +368,6 @@ esphome/components/mcp4461/* @p1ngb4ck esphome/components/mcp4728/* @berfenger esphome/components/mcp47a1/* @jesserockz esphome/components/mcp9600/* @mreditor97 -esphome/components/mcp9808/* @k7hpn esphome/components/md5/* @esphome/core esphome/components/mdns/* @esphome/core esphome/components/media_player/* @jesserockz @@ -353,6 +381,7 @@ esphome/components/midea_ir/* @dudanov esphome/components/mipi_dsi/* @clydebarrow esphome/components/mipi_rgb/* @clydebarrow esphome/components/mipi_spi/* @clydebarrow +esphome/components/mistral_ir/* @lerhum esphome/components/mitsubishi/* @RubyBailey esphome/components/mitsubishi_cn105/* @crnjan esphome/components/mixer/speaker/* @kahrendt @@ -391,6 +420,8 @@ esphome/components/noblex/* @AGalfra esphome/components/noise/* @esphome/core esphome/components/npi19/* @bakerkj esphome/components/nrf52/* @tomaszduda23 +esphome/components/ns2009/* @tchilov +esphome/components/ns2009/touchscreen/* @tchilov esphome/components/number/* @esphome/core esphome/components/one_wire/* @ssieb esphome/components/online_image/* @clydebarrow @guillempages @@ -405,6 +436,7 @@ esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pcf85063/* @brogon esphome/components/pcf8563/* @KoenBreeman esphome/components/pcm5122/* @remcom +esphome/components/pd_pioneer_ir/* @martinsah esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pid/* @OttoWinter esphome/components/pipsolar/* @andreashergert1984 @@ -422,6 +454,8 @@ 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_management/* @rwrozelle esphome/components/power_supply/* @esphome/core esphome/components/preferences/* @esphome/core esphome/components/provisioning/* @esphome/core @@ -429,6 +463,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 @@ -441,6 +476,8 @@ esphome/components/rc522/* @glmnet esphome/components/rc522_i2c/* @glmnet esphome/components/rc522_spi/* @glmnet esphome/components/rd03d/* @jasstrong +esphome/components/remko_ar715/* @naKruul +esphome/components/renogy_inverter_ble/* @emilioaray-dev esphome/components/resampler/speaker/* @kahrendt esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz @@ -460,6 +497,7 @@ esphome/components/runtime_stats/* @bdraco esphome/components/rx8025t/* @remcom esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti +esphome/components/samsung/* @jorofi esphome/components/scd4x/* @martgras @sjtrny esphome/components/script/* @esphome/core esphome/components/sdl/* @bdm310 @clydebarrow @@ -488,11 +526,14 @@ esphome/components/sfa30/* @ghsensdev esphome/components/sfa40/* @NoQuarrel esphome/components/sgp40/* @SenexCrenshaw esphome/components/sgp4x/* @martgras @SenexCrenshaw +esphome/components/sh1122_base/* @pl4nkton +esphome/components/sh1122_spi/* @pl4nkton esphome/components/sha256/* @esphome/core esphome/components/shelly_dimmer/* @edge90 @rnauber esphome/components/sht3xd/* @mrtoy-me esphome/components/sht4x/* @sjtrny esphome/components/shutdown/* @esphome/core @jsuanet +esphome/components/siemens_ira211/* @fonix232 esphome/components/sigma_delta_output/* @Cat-Ion esphome/components/sim800l/* @glmnet esphome/components/sm10bit_base/* @Cossid @@ -509,6 +550,7 @@ esphome/components/sound_level/* @kahrendt esphome/components/spa06_base/* @danielkent-net esphome/components/spa06_i2c/* @danielkent-net esphome/components/spa06_spi/* @danielkent-net +esphome/components/spd2010/* @grischard esphome/components/speaker/* @jesserockz @kahrendt esphome/components/speaker/media_player/* @kahrendt @synesthesiam esphome/components/speaker_source/* @kahrendt @@ -538,6 +580,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 @@ -548,29 +591,38 @@ 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/tca8418/* @zebble 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/teleinfo/* @0hax @FredM67 esphome/components/tem3200/* @bakerkj esphome/components/template/alarm_control_panel/* @grahambrown11 @hwstar 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 esphome/components/tinyusb/* @kbx81 +esphome/components/tinyusb_keyboard/* @frederikbolding esphome/components/tlc5947/* @rnauber esphome/components/tlc5971/* @IJIJI esphome/components/tm1621/* @Philippe12 esphome/components/tm1637/* @glmnet esphome/components/tm1638/* @skykingjwc esphome/components/tm1651/* @mrtoy-me +esphome/components/tmc2209/* @remcom +esphome/components/tmc22xx/* @remcom esphome/components/tmp102/* @timsavage esphome/components/tmp1075/* @sybrenstuvel esphome/components/tmp117/* @Azimath @@ -593,11 +645,13 @@ esphome/components/uart/button/* @ssieb esphome/components/uart/event/* @eoasmxd esphome/components/uart/packet_transport/* @clydebarrow esphome/components/uart_mux/* @kbx81 +esphome/components/uart_tcp/* @Bascht74 esphome/components/udp/* @clydebarrow esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ise/* @pvizeli esphome/components/ufm01/* @ljungqvist esphome/components/ultrasonic/* @ssieb @swoboda1337 +esphome/components/unicode/* @esphome/core esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon esphome/components/usb_cdc_acm/* @kbx81 @@ -620,7 +674,7 @@ esphome/components/web_server_idf/* @dentra esphome/components/weikai/* @DrCoolZic esphome/components/weikai_i2c/* @DrCoolZic esphome/components/weikai_spi/* @DrCoolZic -esphome/components/whirlpool/* @glmnet +esphome/components/whirlpool/* @arpena @glmnet esphome/components/whynter/* @aeonsablaze esphome/components/wiegand/* @ssieb esphome/components/wireguard/* @droscy @lhoracek @thomas0bernard @@ -637,9 +691,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 1619371323..d9a33a23b7 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.10.0-dev +PROJECT_NUMBER = 2026.11.0-dev # 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 d94a7ce41a..c9aa3dba62 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.15.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1 RUN \ platformio settings set enable_telemetry No \ 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/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/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..2b094e67a1 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -7,6 +7,7 @@ import importlib import logging import os from pathlib import Path +import platform import re import sys import time @@ -16,8 +17,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 +36,6 @@ from esphome.const import ( CONF_LOGGER, CONF_MDNS, CONF_MQTT, - CONF_NAME, CONF_NAME_ADD_MAC_SUFFIX, CONF_OTA, CONF_PASSWORD, @@ -48,19 +48,24 @@ from esphome.const import ( CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, + KEY_CORE, KEY_ESP32, + KEY_TARGET_PLATFORM, KEY_VARIANT, + PLATFORM_HOST, SECRETS_FILES, + ErrorFormat, Toolchain, ) from esphome.core import CORE, EsphomeError, coroutine from esphome.enum import StrEnum -from esphome.helpers import get_bool_env, indent, is_ip_address +from esphome.helpers import IS_MACOS, get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log 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, @@ -68,6 +73,7 @@ from esphome.util import ( get_serial_ports, is_picotool_usb_permission_error, list_yaml_files, + read_secret_line, run_external_command, run_external_process, safe_print, @@ -84,7 +90,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 +154,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 +823,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 +834,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 +851,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 +872,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 +988,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 @@ -1219,14 +1236,28 @@ def upload_program( config: ConfigType, args: ArgsProtocol, devices: list[str] ) -> tuple[int, str | None]: host = devices[0] + port_type = get_port_type(host) + ota_key = getattr(args, "ota_key", None) + if ota_key and port_type in (PortType.SERIAL, PortType.BOOTSEL): + _LOGGER.warning( + "The presented OTA key only applies to network uploads; ignored for %s", + host, + ) + platform_upload = platform_hooks.get_platform_hook( CORE.target_platform, "upload_program" ) if platform_upload is not None and platform_upload(config, args, host): + if ota_key and port_type not in (PortType.SERIAL, PortType.BOOTSEL): + # A platform's own network path (nrf52 mcumgr) has no key handshake + _LOGGER.warning( + "The presented OTA key only applies to the %s OTA platform; " + "ignored for %s", + CONF_ESPHOME, + host, + ) return 0, host - port_type = get_port_type(host) - # MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by # _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths. is_partition_table = getattr(args, "partition_table", False) @@ -1269,6 +1300,8 @@ def upload_program( network_devices = _resolve_network_devices(devices, config, args) if chosen_platform == CONF_WEB_SERVER: + if ota_key: + raise EsphomeError(OTA_KEY_PLATFORM_ERROR) if is_partition_table or is_bootloader: raise EsphomeError( f"{option_string} is only supported with the esphome OTA platform; " @@ -1282,6 +1315,17 @@ def upload_program( return _upload_via_native_api(config, network_devices, args) +def _ota_upload_platforms(config: ConfigType) -> dict[str, None]: + """The network OTA platforms the config provides, one entry each, in + config order; the web_server final-validate hook merges duplicates.""" + return { + ota_platform: None + for ota_item in config.get(CONF_OTA, []) + if (ota_platform := ota_item.get(CONF_PLATFORM)) + in (CONF_ESPHOME, CONF_WEB_SERVER) + } + + def _choose_ota_platform(config: ConfigType, requested: str | None) -> str: """Pick the OTA platform to use, optionally honoring ``--ota-platform``. @@ -1289,22 +1333,12 @@ 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 - # membership checks see one entry per platform even if the user has - # multiple ``ota:`` items of the same platform; the web_server OTA - # platform's final-validate hook merges duplicates anyway. - available: dict[str, None] = {} - for ota_item in config.get(CONF_OTA, []): - platform = ota_item.get(CONF_PLATFORM) - if platform in (CONF_ESPHOME, CONF_WEB_SERVER): - available[platform] = None - + available = _ota_upload_platforms(config) if not available: raise EsphomeError( f"Cannot upload Over the Air as the {CONF_OTA} configuration is not " @@ -1342,9 +1376,19 @@ def _upload_via_native_api( # Fail closed: an encryption block whose key did not resolve must never # fall back to a plaintext upload noise_psk = None + old_noise_psk = None plaintext_fallback = False - if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None: + allow_plaintext_upload = False + if ota_key := getattr(args, "ota_key", None): + # The user presents a key the device still runs instead of the + # configured one (a key change in progress); an explicit key never + # downgrades to plaintext + noise_psk = ota_key + elif (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; " @@ -1353,6 +1397,8 @@ def _upload_via_native_api( # Ensure the key is a string, as required by the underlying OTA implementation. # It arrives here as a SensitiveStr which aioesphomeapi rejects. noise_psk = str(noise_psk) + if old_key := encryption_conf.get(espota2.CONF_OLD_KEY): + old_noise_psk = str(old_key) elif api_key := static_encryption_key(config.get(CONF_API) or {}): # Remove before 2027.3.0: the api key is tried, falling back to plaintext noise_psk = str(api_key) @@ -1377,6 +1423,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,9 +1447,23 @@ def _upload_via_native_api( ota_type, noise_psk, plaintext_fallback=plaintext_fallback, + allow_plaintext_upload=allow_plaintext_upload, + old_noise_psk=old_noise_psk, ) +def _old_key_notice(config: ConfigType) -> None: + """After `run`, whose image was just built from this config; a plain + `upload` may send a stale image that still carries the previous key.""" + from esphome import espota2 + + for item in config.get(CONF_OTA, []): + if item.get(CONF_PLATFORM) == CONF_ESPHOME and ( + item.get(CONF_ENCRYPTION) or {} + ).get(espota2.CONF_OLD_KEY): + _LOGGER.warning(espota2.OLD_KEY_REMOVE_NOTICE) + + def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: @@ -1709,27 +1775,66 @@ 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 +ENV_OTA_KEY = "ESPHOME_OTA_KEY" +OTA_KEY_PLATFORM_ERROR = ( + f"--prompt-ota-key and {ENV_OTA_KEY} only apply to the {CONF_ESPHOME} OTA " + "platform; to flash by serial instead, name the port with --device" +) - 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 _read_ota_key(args: ArgsProtocol, config: ConfigType) -> None: + """Take the key to present to the device for this upload, from the + prompt or from ESPHOME_OTA_KEY, before any compile so the prompt is + not buried in build output.""" + prompt = getattr(args, "prompt_ota_key", False) + env_key = getattr(args, "env_ota_key", None) + if not prompt and env_key is None: + return + if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_HOST: + _LOGGER.warning("A host program runs locally; the presented OTA key is ignored") + return + # The HTTP path has no key handshake; decide before prompting or + # compiling. A serial target is a serial flash: the key is not even + # asked for, and --ota-platform means nothing to it. Every entry must + # be serial: a missing port is skipped and the next entry may be a host + devices = getattr(args, "device", None) or [] + serial_target = bool(devices) and all( + device == "SERIAL" + or get_port_type(device) in (PortType.SERIAL, PortType.BOOTSEL) + for device in devices + ) + chosen = ( + _choose_ota_platform(config, getattr(args, "ota_platform", None)) + if not serial_target and _ota_upload_platforms(config) + else None + ) + if chosen == CONF_WEB_SERVER: + raise EsphomeError(OTA_KEY_PLATFORM_ERROR) + from esphome.components.noise import validate_encryption_key + from esphome.config_validation import Invalid + + if serial_target: + _LOGGER.warning( + "The presented OTA key only applies to network uploads; ignored for %s", + ", ".join(devices), + ) + return + key = read_secret_line("OTA encryption key: ") if prompt else env_key + try: + validate_encryption_key(key) + except Invalid as err: + raise EsphomeError(f"Invalid OTA encryption key: {err}") from err + args.ota_key = key def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: + _read_ota_key(args, config) # Get devices, resolving special identifiers like OTA devices = choose_upload_log_host( default=args.device, @@ -1765,6 +1870,19 @@ 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 + _read_ota_key(args, config) exit_code = write_cpp(config) if exit_code != 0: return exit_code @@ -1773,7 +1891,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) @@ -1790,6 +1908,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: exit_code, successful_device = upload_program(config, args, devices) if exit_code == 0: _LOGGER.info("Successfully uploaded program.") + _old_key_notice(config) else: _LOGGER.warning("Failed to upload to %s", devices) return exit_code @@ -1926,7 +2045,10 @@ def run_multiple_configs( safe_print() cmd = command_builder(f) - rc = run_external_process(*cmd) + # The parent already logged the Intel macOS warning; children skip it. + rc = run_external_process( + *cmd, env={**os.environ, _INTEL_MACOS_WARNED_ENV: "1"} + ) if rc == 0: print_bar(f"[{color(AnsiFore.BOLD_GREEN, 'SUCCESS')}] {str(f)}") @@ -1965,12 +2087,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 +2131,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 +2158,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 +2235,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 +2272,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. @@ -2286,6 +2304,25 @@ def _add_states_args(parser: argparse.ArgumentParser) -> None: ) +def _error_format(value: str) -> ErrorFormat: + try: + return ErrorFormat(value.lower()) + except ValueError: + choices = ", ".join(ErrorFormat) + raise argparse.ArgumentTypeError( + f"invalid choice: '{value}' (choose from {choices})" + ) from None + + +ARGUMENT_HELP_PROMPT_OTA_KEY = ( + "Ask for the OTA encryption key to present to the device instead of using " + "the one in the configuration, for a device that still runs a previous key. " + "The key is read without echo, or as one line from stdin when that is not " + "a terminal; it is never taken from the command line. A calling program " + f"can hand the key over in the {ENV_OTA_KEY} environment variable instead." +) + + def parse_args(argv): options_parser = argparse.ArgumentParser(add_help=False) options_parser.add_argument( @@ -2343,7 +2380,19 @@ 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}." + ), + ) + + options_parser.add_argument( + "--error-format", + type=_error_format, + metavar="{" + ",".join(f.value for f in ErrorFormat) + "}", + help=( + "How configuration errors are shown. 'yaml' shows them inside the " + "failing YAML; 'line' prints one 'file:line:column: error: message' " + "line per error. Default: $ESPHOME_ERROR_FORMAT, else 'yaml'." ), ) @@ -2403,6 +2452,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", @@ -2441,6 +2491,11 @@ def parse_args(argv): help="Upload as partition table (OTA).", action="store_true", ) + parser_upload.add_argument( + "--prompt-ota-key", + action="store_true", + help=ARGUMENT_HELP_PROMPT_OTA_KEY, + ) parser_upload.add_argument( "--bootloader", help="Upload as bootloader (OTA).", @@ -2466,7 +2521,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 +2554,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 +2563,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", @@ -2520,6 +2576,11 @@ def parse_args(argv): "(HTTP Basic auth) when that is the only configured platform." ), ) + parser_run.add_argument( + "--prompt-ota-key", + action="store_true", + help=ARGUMENT_HELP_PROMPT_OTA_KEY, + ) parser_clean = subparsers.add_parser( "clean-mqtt", @@ -2648,9 +2709,18 @@ def parse_args(argv): args, unknown_args = parser.parse_known_args(arguments) if unknown_args: _LOGGER.warning("Ignored unrecognized arguments: %s", unknown_args) - return args + else: + args = parser.parse_args(arguments) - return parser.parse_args(arguments) + # Read here rather than as the option default, so a bad value is reported + # against the environment variable instead of --error-format + if args.error_format is None: + env_value = os.getenv("ESPHOME_ERROR_FORMAT", ErrorFormat.YAML.value) + try: + args.error_format = _error_format(env_value) + except argparse.ArgumentTypeError as err: + parser.error(f"ESPHOME_ERROR_FORMAT: {err}") + return args def _warn_if_source_tree_mismatch() -> None: @@ -2696,12 +2766,34 @@ def _warn_if_source_tree_mismatch() -> None: ) +_INTEL_MACOS_REMOVAL = "2027.6.0" +_INTEL_MACOS_WARNED_ENV = "ESPHOME_INTEL_MACOS_WARNED" + + +def _warn_if_intel_macos() -> None: + """Warn that Intel (x86_64) Python on macOS loses support by _INTEL_MACOS_REMOVAL.""" + if ( + not IS_MACOS + or platform.machine() != "x86_64" + or _INTEL_MACOS_WARNED_ENV in os.environ + ): + return + _LOGGER.warning( + "Support for Intel Macs will end in ESPHome %s or earlier. The Python " + "packages ESPHome depends on have stopped publishing Intel macOS builds, " + "so future releases will not install on this machine. On an Apple " + "Silicon Mac, switch to a native arm64 Python.", + _INTEL_MACOS_REMOVAL, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache args = parse_args(argv) CORE.dashboard = args.dashboard CORE.testing_mode = args.testing_mode + CORE.error_format = args.error_format # Create address cache from command-line arguments CORE.address_cache = AddressCache.from_cli_args( @@ -2714,7 +2806,25 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + # Taken out of the environment before any command runs, so no build + # tool inherits a key a calling program handed over + args.env_ota_key = os.environ.pop(ENV_OTA_KEY, None) + if args.command in ("upload", "run"): + # One key is one device's; several configs run in child processes + # that would never see it + if (args.env_ota_key is not None or args.prompt_ota_key) and len( + args.configuration + ) > 1: + raise EsphomeError( + f"--prompt-ota-key and {ENV_OTA_KEY} take one configuration; " + f"{len(args.configuration)} were given" + ) + elif args.env_ota_key is not None: + _LOGGER.warning( + "%s is set but the %s command does not use it", ENV_OTA_KEY, args.command + ) _warn_if_source_tree_mismatch() + _warn_if_intel_macos() if args.command in PRE_CONFIG_ACTIONS: try: @@ -2758,6 +2868,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/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..16fd5d6282 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.3", + "3af533a85163804e164500a5b254dc23b922080e25ecb7dc37b3dd8ac84651db", + 37185650, + ), +} + +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.3" +_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": ( + "ad0ea929238d9b527c43e0d20257acc2a5680bdb0d2b1cf8f2c99169786217e9", + 60747260, + ), + "darwin_x86_64": ( + "997090cac80c8eb59604ec52b73fb3568e5f2d9b2971f83c3f2a5c545c9c949c", + 64065752, + ), + "linux_aarch64": ( + "403f14d0d5755682fcd6eaea1e4c457a1d8cad30690e52fb0c804d8db266b908", + 67487067, + ), + "linux_x86_64": ( + "64f21fa3ba736b363dc8c234dbbb39e005e574e33de08a78e9a00c55a64ae706", + 68343668, + ), + "windows_amd64": ( + "1e62abf8df041ce5158f5770dffef955f2f981ce7f7adee88fcd5a8197705aa6", + 67536405, + ), +} 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 4a67cd31ba..393a79422b 100644 --- a/esphome/automation.py +++ b/esphome/automation.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field import logging import string @@ -21,9 +21,9 @@ from esphome.const import ( CONF_TYPE_ID, CONF_UPDATE_INTERVAL, ) -from esphome.core import CORE, ID, EsphomeError, Lambda +from esphome.core import CORE, ID, EsphomeError, HexInt, Lambda from esphome.cpp_generator import ( - FlashStringLiteral, + Expression, LambdaExpression, MockObj, MockObjClass, @@ -35,6 +35,32 @@ 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 @@ -219,9 +245,7 @@ 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)) + return str(cg.progmem_string(value)) def literal_with_length(config: ConfigType, value: str) -> str: @@ -233,15 +257,26 @@ def literal_with_length(config: ConfigType, value: str) -> str: return f"{cg.safe_exp(value)}, {len(value.encode('utf-8'))}" +def string_ref_literal(config: ConfigType, value: str) -> str: + """Renderer for a ``StringRef`` comparison: a flash literal on ESP8266, else ``StringRef(literal, length)``.""" + if CORE.is_esp8266: + return str(cg.FlashStringLiteral(value)) + return f"StringRef({literal_with_length(config, value)})" + + +def _names_parent(text: str) -> bool: + return any(f == "parent" for _, f, _, _ in string.Formatter().parse(text)) + + @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 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. + path into nested sections. ``target`` and a plain ``str`` ``type_`` (raw C++ type text) may + name the parent object as ``{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 @@ -251,13 +286,13 @@ class ApplyCall: fields = [ f for _, f, _, _ in string.Formatter().parse(self.target) if f is not None ] - if any(fields): + if any(f not in ("", "parent") for f in fields): raise ValueError( - f"apply target {self.target!r}: only bare {{}} placeholders" + f"apply target {self.target!r}: only {{}} and {{parent}} placeholders" ) - if len(fields) != len(self.args): + if (count := fields.count("")) != len(self.args): raise ValueError( - f"apply target {self.target!r} has {len(fields)} " + f"apply target {self.target!r} has {count} " f"placeholder(s) for {len(self.args)} config key(s)" ) if any(len(arg) not in (2, 3) for arg in self.args): @@ -265,6 +300,12 @@ class ApplyCall: f"apply target {self.target!r}: each arg is (conf_key, type_[, const_fn])" ) + @property + def names_parent(self) -> bool: + return _names_parent(self.target) or any( + isinstance(arg[1], str) and _names_parent(arg[1]) for arg in self.args + ) + @property def members(self) -> list[tuple[Any, Any, Any]]: """Each arg as ``(conf_key, type_, const_fn or None)``.""" @@ -280,8 +321,8 @@ class ApplyField: 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 bypasses it, - so the target must also take a plain ``type_``. An absent key emits nothing. + 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, ...] @@ -339,27 +380,48 @@ def _check_key_in_schema( schema = schema.schema[markers[part]] +def parent_ref(var: MockObj) -> MockObj: + """``var`` named from global scope, so a trigger argument cannot shadow it. + + Also how a generated callback names its Automation. + """ + return MockObj(f"::{var}", "->") + + 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])}" + return str(parent_ref(await cg.get_variable(config[id_key]))) def _apply_lambda_args(args: TemplateArgsType) -> TemplateArgsType: - # Must match ApplyAction::ApplyFn and ApplyCondition::CheckFn exactly for the function - # pointer conversion. + # 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, + parent: str | None, lambda_args: TemplateArgsType, compare: bool = False, ) -> list[str]: @@ -380,6 +442,9 @@ async def _render_values( 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: @@ -400,8 +465,25 @@ def register_apply_action( ) -> None: """Register an action that only forwards config values to its parent, with no C++ class. - Generates one stateless function for ``ApplyAction``: the parent (read from - ``id_key``) and constants are baked in, lambdas are called inline with the trigger args. + See ``apply_action_builder``. + """ + register_action(name, ApplyAction, schema, synchronous=True)( + apply_action_builder(name, schema, *fields, call=call, id_key=id_key) + ) + + +def apply_action_builder( + name: str, + schema: cv.Schema, + *fields: ApplyField | ApplyCall, + call: str | None = None, + id_key: str = CONF_ID, +) -> Callable[..., Awaitable[MockObj]]: + """The builder ``register_apply_action`` registers, for a caller that wraps it. + + 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. """ @@ -433,24 +515,44 @@ def register_apply_action( 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): + if not _apply_call_active(members, values): continue exprs = await _render_values( name, target, members, values, config, parent, lambda_args ) - statements.append(f"{receiver}{target.format(*exprs)};") + statements.append(f"{receiver}{target.format(*exprs, parent=parent)};") if call: statements = [ f"auto apply_call = {parent}->{call}();", *statements, "apply_call.perform();", ] - apply_lambda = LambdaExpression( - ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + return _apply_function( + action_id, cg.void, template_arg, lambda_args, statements ) - return cg.new_Pvariable(action_id, template_arg, apply_lambda) - register_action(name, ApplyAction, schema, synchronous=True)(builder) + return builder + + +def _apply_call_active(members: list[tuple[Any, Any, Any]], values: list[Any]) -> bool: + """An ``ApplyCall`` is emitted unless it has keys and none of them is set.""" + return not members or any(value is not None for value in values) + + +async def _render_check( + name: str, + target: str, + members: list[tuple[Any, Any, Any]], + values: list[Any], + config: ConfigType, + parent: str | None, + lambda_args: TemplateArgsType, +) -> str: + """Render a boolean check with ``values`` compared against config.""" + exprs = await _render_values( + name, target, members, values, config, parent, lambda_args, compare=True + ) + return target.format(*exprs, parent=parent) def register_apply_condition( @@ -462,7 +564,7 @@ def register_apply_condition( ``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 stateless function for ``ApplyCondition``. + Generates one static predicate and an ``ApplyCondition`` templated on it. """ call = check if isinstance(check, ApplyCall) else ApplyCall(check) members = call.members @@ -478,23 +580,17 @@ def register_apply_condition( ) -> 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, + values = _apply_values(config, members) + check = await _render_check( + name, call.target, members, values, config, parent, lambda_args ) - check_lambda = LambdaExpression( - [f"return {parent}->{call.target.format(*exprs)};"], + return _apply_function( + condition_id, + cg.bool_, + template_arg, lambda_args, - capture="", - return_type=cg.bool_, + [f"return {parent}->{check};"], ) - return cg.new_Pvariable(condition_id, template_arg, check_lambda) register_condition(name, ApplyCondition, schema)(builder) @@ -629,54 +725,22 @@ XorCondition = cg.esphome_ns.class_("XorCondition", Condition) @register_condition("and", AndCondition, validate_condition_list) -async def and_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - -@register_condition("or", OrCondition, validate_condition_list) -async def or_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("all", AndCondition, validate_condition_list) -async def all_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - +@register_condition("or", OrCondition, validate_condition_list) @register_condition("any", OrCondition, validate_condition_list) -async def any_condition_to_code( +@register_condition("xor", XorCondition, validate_condition_list) +async def condition_group_to_code( config: ConfigType, condition_id: ID, template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: conditions = await build_condition_list(config, template_arg, args) + # A group of one is that condition for and, or and xor; skip the wrapper. + if len(conditions) == 1: + return conditions[0] return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions + condition_id, cg.TemplateArguments(len(conditions), *template_arg), *conditions ) @@ -691,19 +755,6 @@ async def not_condition_to_code( return cg.new_Pvariable(condition_id, template_arg, condition) -@register_condition("xor", XorCondition, validate_condition_list) -async def xor_condition_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - conditions = await build_condition_list(config, template_arg, args) - return cg.new_Pvariable( - condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions - ) - - @register_condition("lambda", LambdaCondition, cv.returning_lambda) async def lambda_condition_to_code( config: ConfigType, @@ -805,11 +856,13 @@ async def if_action_to_code( condition = await build_condition(config[cond_conf], template_arg, args) var = cg.new_Pvariable(action_id, if_template_arg, condition) if CONF_THEN in config: - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) if has_else: - actions = await build_action_list(config[CONF_ELSE], template_arg, args) - cg.add(var.add_else(actions)) + for action in await build_action_list(config[CONF_ELSE], template_arg, args): + cg.add(var.add_else_action(action)) + cg.add(var.finish_else()) return var @@ -832,8 +885,9 @@ async def while_action_to_code( ) -> MockObj: condition = await build_condition(config[CONF_CONDITION], template_arg, args) var = cg.new_Pvariable(action_id, template_arg, condition) - actions = await build_action_list(config[CONF_THEN], template_arg, args) - cg.add(var.add_then(actions)) + for action in await build_action_list(config[CONF_THEN], template_arg, args): + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -862,7 +916,9 @@ async def repeat_action_to_code( cg.TemplateArguments(cg.uint32, *template_arg.args), [(cg.uint32, "iteration"), *args], ) - cg.add(var.add_then(actions)) + for action in actions: + cg.add(var.add_then_action(action)) + cg.add(var.finish_then()) return var @@ -998,30 +1054,141 @@ def has_non_synchronous_actions(actions: ConfigType) -> bool: Non-synchronous actions (delay, wait_until, script.wait, etc.) store trigger args for later execution, making non-owning types like StringRef unsafe. + + Accepts an action list, a single action, or an automation. Only the keys of + action entries are looked up as action names; the options of an action are + only searched for nested action lists (``then:``, ``else:``, ...), so an + option that shares a name with an action, such as the ``delay:`` option of + ``speaker_source.set_playlist_delay``, is not mistaken for that action. """ if isinstance(actions, list): return any(has_non_synchronous_actions(item) for item in actions) if isinstance(actions, dict): - for key in actions: + for key, options in actions.items(): if key in ACTION_REGISTRY and not ACTION_REGISTRY[key].synchronous: return True - return any( - has_non_synchronous_actions(v) - for v in actions.values() - if isinstance(v, (list, dict)) - ) + if _options_have_non_synchronous_actions(options): + return True return False +def _options_have_non_synchronous_actions(options: Any) -> bool: + """Search an action's options for nested action lists, without treating option keys as actions.""" + if isinstance(options, list): + return has_non_synchronous_actions(options) + if isinstance(options, dict): + return any(_options_have_non_synchronous_actions(v) for v in options.values()) + return False + + +def validate_no_deferring_actions( + context: str, data: str = "the trigger data" +) -> Callable[[ConfigType], ConfigType]: + """Return a validator that rejects deferring actions in a validated automation. + + Use it for triggers whose arguments only live while the callback runs (views into a reused + buffer, a JSON document freed on return, ...): deferring actions keep the arguments for later. + + :param context: Where the automation is, for the error message, e.g. ``"on_json and on_data"``. + :param data: What is only valid while the automation runs, for the error message. + """ + + def validator(value: ConfigType) -> ConfigType: + if has_non_synchronous_actions(value): + raise cv.Invalid( + f"Deferring actions (delay, wait_until, script.wait, ...) are not allowed in " + f"{context}: {data} is only valid while the automation runs. Copy what you need " + "into globals first, then defer in a separate script or automation." + ) + return value + + return validator + + +def validate_synchronous_automation( + context: str, + data: str = "the trigger data", + extra_schema=None, + extra_validators=None, + single: bool = False, +) -> cv.All: + """``validate_automation()`` that also rejects deferring actions. + + See ``validate_no_deferring_actions()`` for ``context`` and ``data``; the other parameters are + passed to ``validate_automation()``. + """ + return cv.All( + validate_automation( + extra_schema=extra_schema, + extra_validators=extra_validators, + single=single, + ), + validate_no_deferring_actions(context, data), + ) + + +async def _new_automation( + args: TemplateArgsType, config: ConfigType, *ctor_args: MockObj +) -> MockObj: + """Create the Automation for ``config`` with its actions.""" + templ = cg.TemplateArguments(*(arg[0] for arg in args)) + obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, *ctor_args) + actions = await build_action_list(config[CONF_THEN], templ, args) + for action in actions: + cg.add(obj.add_action(action)) + return obj + + async def build_automation( trigger: MockObj, args: TemplateArgsType, config: ConfigType ) -> MockObj: - arg_types = [arg[0] for arg in args] - templ = cg.TemplateArguments(*arg_types) - obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ, trigger) - actions = await build_action_list(config[CONF_THEN], templ, args) - cg.add(obj.add_actions(actions)) - return obj + return await _new_automation(args, config, trigger) + + +async def build_trigger_callback( + args: TemplateArgsType, + config: ConfigType, + params: TemplateArgsType, + forward: Sequence[str | Expression] | None = None, + when: str | ApplyCall | None = None, + parent: MockObj | None = None, +) -> LambdaExpression: + """Build the Automation for ``config`` and return a stateless callback that triggers it. + + ``params`` are the parent callback's parameters, ``forward`` the expressions passed to + ``trigger()`` (default: the parameter names; write the parent as ``parent_ref(var)``), + ``when`` a filter the callback returns early on, skipped like any ``ApplyCall`` when none + of its keys is set. ``when`` may name ``parent`` as ``{parent}``, e.g. + ``"{parent}->is_fully_open()"``. + """ + members: list[tuple[Any, Any, Any]] = [] + if when is not None: + call = when if isinstance(when, ApplyCall) else ApplyCall(when) + members = call.members + if parent is None and call.names_parent: + raise ValueError(f"trigger filter {call.target!r} names {{parent}}") + obj = await _new_automation(args, config) + lambda_args = _apply_lambda_args(params) + statements: list[str] = [] + if when is not None: + values = _apply_values(config, members) + if _apply_call_active(members, values): + check = await _render_check( + "trigger filter", + call.target, + members, + values, + config, + None if parent is None else str(parent_ref(parent)), + lambda_args, + ) + statements.append(f"if (!({check}))\n return;") + if forward is None: + forward = [name for _, name in params] + statements.append(f"{parent_ref(obj)}->trigger({', '.join(map(str, forward))});") + return LambdaExpression( + ["\n".join(statements)], lambda_args, capture="", return_type=cg.void + ) async def build_callback_automation( @@ -1030,6 +1197,9 @@ async def build_callback_automation( args: TemplateArgsType, config: ConfigType, forwarder: MockObj | MockObjClass | None = None, + params: TemplateArgsType | None = None, + forward: Sequence[str | Expression] | None = None, + when: str | ApplyCall | None = None, ) -> None: """Build an Automation and register it as a callback on the parent. @@ -1041,6 +1211,9 @@ async def build_callback_automation( pointer-sized (single Automation* field) to fit inline in Callback::ctx_ and avoid heap allocation. + With ``params``, ``forward`` or ``when`` the callback is instead the stateless + lambda of ``build_trigger_callback``; ``forwarder`` cannot be combined with them. + :param parent: The component object (e.g., button, sensor). :param callback_method: Name of the callback method (e.g., "add_on_press_callback"). :param args: Automation template args as list of (type, name) tuples. @@ -1049,22 +1222,56 @@ async def build_callback_automation( TriggerForwarder. Pass any struct type whose aggregate init takes a single Automation pointer (e.g., TriggerOnTrueForwarder). """ - arg_types = [arg[0] for arg in args] - templ = cg.TemplateArguments(*arg_types) - obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ) - actions = await build_action_list(config[CONF_THEN], templ, args) - cg.add(obj.add_actions(actions)) + if params is not None or forward is not None or when is not None: + if forwarder is not None: + raise ValueError( + "forwarder cannot be combined with params, forward or when" + ) + callback = await build_trigger_callback( + args, config, args if params is None else params, forward, when, parent + ) + cg.add(getattr(parent, callback_method)(callback)) + return + obj = await _new_automation(args, config) # Use template forwarder structs for deduplication. The compiler generates # one operator() per forwarder type; different automation pointers are just # data in the struct. if forwarder is None: - forwarder = TriggerForwarder.template(templ) + forwarder = TriggerForwarder.template(*(arg[0] for arg in args)) # RawExpression for aggregate init — both forwarder and obj are codegen # MockObjs (not user input), and there's no Expression type for positional # aggregate initialization (StructInitializer uses named fields). cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}"))) +async def build_parent_callback_automation( + parent: MockObj, callback_method: str, arg: tuple[Any, str], config: ConfigType +) -> None: + """Register an Automation that receives ``parent`` from a callback that carries nothing. + + ``arg`` is the automation's ``(type, name)``, e.g. ``(Fan.operator("ptr"), "x")``. + """ + await build_callback_automation( + parent, callback_method, [arg], config, params=[], forward=[parent_ref(parent)] + ) + + +async def build_trigger_automations( + parent: MockObj | None, + config: ConfigType, + entries: tuple[tuple[str, TemplateArgsType], ...], +) -> None: + """Instantiate each entry's Trigger class, with ``parent`` when given, and build its automations. + + ``entries`` are ``(conf_key, args)`` pairs; the class comes from the entry's ``CONF_TRIGGER_ID``. + """ + ctor_args = () if parent is None else (parent,) + for conf_key, args in entries: + for conf in config.get(conf_key, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], *ctor_args) + await build_automation(trigger, args, conf) + + @dataclass(frozen=True, slots=True) class CallbackAutomation: """A single callback automation entry for build_callback_automations.""" @@ -1073,6 +1280,9 @@ class CallbackAutomation: callback_method: str args: TemplateArgsType = field(default_factory=list) forwarder: MockObj | MockObjClass | None = None + params: TemplateArgsType | None = None + forward: Sequence[str | Expression] | None = None + when: str | ApplyCall | None = None async def build_callback_automations( @@ -1094,4 +1304,7 @@ async def build_callback_automations( entry.args, conf, forwarder=entry.forwarder, + params=entry.params, + forward=entry.forward, + when=entry.when, ) diff --git a/esphome/build_gen/arduino8266.py b/esphome/build_gen/arduino8266.py index b6ac88f123..b0ba766392 100644 --- a/esphome/build_gen/arduino8266.py +++ b/esphome/build_gen/arduino8266.py @@ -14,7 +14,6 @@ from the build flags with the same precedence as the PlatformIO builder. from __future__ import annotations -from collections.abc import Collection from dataclasses import dataclass import hashlib import logging @@ -23,15 +22,26 @@ from pathlib import Path import re import shlex import subprocess -import sys 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, @@ -44,14 +54,12 @@ from esphome.components.esp8266.const import ( KEY_ESP8266, KEY_FLASH_SIZE, KEY_SCANF_FLOAT, + THROW_STUBS_HEADER, ) from esphome.core import CORE, EsphomeError -from esphome.framework_helpers import ( - get_project_cxx_compile_flags, - strip_win_long_path_prefix, -) +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 SOURCE_KIND_FOR_SUFFIX, lex_build_flags +from esphome.platformio.library import lex_build_flags if TYPE_CHECKING: from esphome.arduino8266.framework import InstalledPaths @@ -643,14 +651,6 @@ _PLAIN_LINKER_PREFIXES = ("-T", "-Xlinker") _PLAIN_DRIVER_LINK_PREFIXES = ("-fuse-ld=", "--specs=", "-specs=") -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 _stat_sig(path: Path) -> str: """Size and mtime cache-stamp signature for one input file. @@ -868,34 +868,6 @@ def _generate_testing_flash_ld( ) -def _ninja_compile_edges( - lines: list[str], - sources: list[Path], - root: Path, - group: str, - flags: str = "", -) -> list[str]: - """Emit compile edges for ``sources``; return the object paths.""" - objects = [] - for src in sources: - rel = src.relative_to(root).as_posix() - obj = f"obj/{group}/{rel}.o" - escaped_obj = _e(obj) - lines.append( - f"build {escaped_obj}: {SOURCE_KIND_FOR_SUFFIX[src.suffix]} {_e(src)}" - ) - if flags: - lines.append(f" flags = {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 _common_parent(paths: list[Path]) -> Path: - return Path(os.path.commonpath([str(p.parent) for p in paths])) - - class _FlagSets(NamedTuple): cflags: list[str] cxxflags: list[str] @@ -1082,57 +1054,22 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: + ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"] ) - build_tool = Path(__file__).parent / "build_tool.py" - - # $in/$out stay unquoted: ninja escapes its built-in path variables - # itself; only literal paths need _q(). + cxx = (toolchain_tool(paths.toolchain, "g++"),) lines = [ - "# Auto-generated by ESPHome", - "ninja_required_version = 1.5", - f"cc = {_q(toolchain_tool(paths.toolchain, 'gcc'))}", - f"cxx = {_q(toolchain_tool(paths.toolchain, 'g++'))}", - # 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 ''}", - "", - # Rule names match SOURCE_KIND_FOR_SUFFIX values (c, cxx, asm, aspp) - "rule c", - " command = $ccache $cc -MMD -MF $out.d $cflags $flags -c $in -o $out", - " depfile = $out.d", - " deps = gcc", - " description = CC $out", - "rule cxx", - " command = $ccache $cxx -MMD -MF $out.d $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 $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", - "rule ar", - f" command = $python $buildtool ar {_q(toolchain_tool(paths.toolchain, 'ar'))} $out $out.rsp", - " rspfile = $out.rsp", - " rspfile_content = $in_newline", - " description = AR $out", + *tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache), + *compile_rule_lines(ccache), + *ar_rule_lines(toolchain_tool(paths.toolchain, "ar")), + *pch_rule_lines(), "rule link", " command = $cxx -o $out $linkflags @$out.rsp $libdirflags -Wl,--start-group $archives $libflags -Wl,--end-group", " rspfile = $out.rsp", " rspfile_content = $in_newline", " description = LINK $out", "rule elf2bin", - # --flash_size deliberately stays board-derived, as under - # PlatformIO (which reads upload.maximum_size, not the ldscript). - f" command = $python {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out", + # --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", @@ -1151,58 +1088,10 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: if "USE_ESP8266_WAVEFORM_STUBS" in flag_defines: core_exclude |= _CORE_EXCLUDE_WAVEFORM - archives = [] - direct_objs: list[str] = [] - # variant_dir existence was already enforced with the include dirs - variant_sources = _collect_sources(variant_dir) - if variant_sources: - objs = _ninja_compile_edges(lines, variant_sources, variant_dir, "variant") - lines.append(f"build libFrameworkArduinoVariant.a: ar {' '.join(objs)}") - archives.append("libFrameworkArduinoVariant.a") - - core_objs = _ninja_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") - - 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 - lib_root = _common_parent(lib.sources) - objs = _ninja_compile_edges( - lines, - lib.sources, - lib_root, - f"lib/{lib.name}", - flags=" ".join(_shell_token(f) for f in lib.flags), - ) - 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) - - # 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_parts: list[str] = [] + # esp8266/__init__ pins build_src_flags (the throw_stubs force-include), as on + # the PlatformIO path; -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") ) @@ -1213,15 +1102,71 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool: raise EsphomeError( "build_src_flags has a trailing '-include' with no header" ) - src_parts.append(f"-include {_q(src_dir / 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_parts.append(_shell_token(tok)) - src_extra = " ".join(src_parts) + src_other.append(_shell_token(tok)) + # The throw stubs abort: none anywhere with exceptions on, else the core and + # libraries get them too (src keeps them after its pch include) + if config.exceptions: + src_includes = [h for h in src_includes if h != THROW_STUBS_HEADER] + include_flags = [f"-include {_q(src_dir / h)}" for h in src_includes] + framework_flags = "" + if THROW_STUBS_HEADER in src_includes: + stubs_flag = include_flags[src_includes.index(THROW_STUBS_HEADER)] + lines.append(f"frameworkflags = {stubs_flag}") + framework_flags = "$frameworkflags" + 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", flags=framework_flags + ) + 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", + flags=framework_flags, + ) + 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, framework_flags) + archives += lib_archives + # One shared variable instead of repeating the flags line on every src # edge (hundreds of edges in a real project) - lines.append(f"srcflags = {src_extra}") - src_objs = _ninja_compile_edges( - lines, _collect_sources(src_dir), src_dir, "src", flags="$srcflags" + 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}"] @@ -1259,3 +1204,28 @@ 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 7689fc93b0..a826a2b628 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,114 @@ 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 _cmake_args_for_write(minimal: bool) -> dict[str, str]: + """The cg.add_cmake_arg() values for one project write. + + The discovery (minimal) write lets every built-in component register so + the list it finds, cached per target, serves any exclusion set; the full + write drops the excluded names from that list and passes all of them to + IDF. Names outside the framework's components directory (managed + components) stay excluded on the discovery write too, since the list + only holds built-in ones. A checkout supplied through IDF_PATH caches + nothing, so its discovery keeps every exclusion. + """ + from esphome.espidf.toolchain import _esphome_manages_idf, _get_idf_path + + args = dict(CORE.cmake_args) + if ( + not minimal + or not _esphome_manages_idf() + or not (excluded := args.get("EXCLUDE_COMPONENTS")) + ): + return args + root = _get_idf_path() / "components" + outside = [name for name in excluded.split(";") if not (root / name).is_dir()] + if outside: + args["EXCLUDE_COMPONENTS"] = ";".join(outside) + else: + del args["EXCLUDE_COMPONENTS"] + return args + + def get_project_cmakelists( minimal: bool = False, builtin_components: list[str] | None = None ) -> str: @@ -123,13 +311,28 @@ 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 - # (discovery) writes too so excluded components never register. + # set when project.cmake seeds the component list. cmake_args = "\n".join( f"set({name} {_cmake_quote(value)})" - for name, value in sorted(CORE.cmake_args.items()) + for name, value in sorted(_cmake_args_for_write(minimal).items()) ) # Per-project list exposed as a CMake variable so converted PIO libs @@ -152,10 +355,10 @@ def get_project_cmakelists( # component's REQUIRES including real IDF components). Referenced by # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped # on minimal writes because project_description.json may be stale. - # Excluded components are dropped here as well: a stale - # project_description.json from a build without exclusions may still - # list them, and requiring an excluded component pulls it back into - # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS). + # Excluded components are dropped here as well: the discovery list + # (cached per target) still holds them, and requiring an excluded + # component pulls it back into the build (IDF requirement expansion + # overrides EXCLUDE_COMPONENTS). # Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the # two can never disagree within one generated file. builtin_components_property = ( @@ -200,6 +403,9 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{IDF_BOOTLOADER_OVERRIDE} +{ldgen_override} + {cpp_standard_options} {cxx_compile_options} @@ -212,14 +418,31 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) project({CORE.name}) +{ldgen_override_check} + +{_lwip_empty_sources_filter()} + # Emit per-memory-type JSON size data for ESPHome to read post-build. # json2 stays small; raw dumps every symbol (~2s on a large map) and # this command runs inside the link edge, blocking everything downstream. +# IDF's size target depends on the map, so ninja has to know the link +# writes it for size to build in the same run as all. IDF declares the +# map itself since espressif/esp-idf#19201 (and in cmakev2), and ninja +# rejects two rules for one output, so declare it here only when IDF has +# not. add_custom_command(OUTPUT) marks its outputs GENERATED. +get_source_file_property(esphome_map_declared + ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map GENERATED) +if(esphome_map_declared) + set(esphome_map_byproducts) +else() + set(esphome_map_byproducts BYPRODUCTS ${{CMAKE_BINARY_DIR}}/${{CMAKE_PROJECT_NAME}}.map) +endif() add_custom_command( TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2 -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json ${{CMAKE_PROJECT_NAME}}.map + ${{esphome_map_byproducts}} WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}} VERBATIM ) @@ -282,9 +505,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: @@ -305,10 +591,9 @@ def write_project( ) # Snapshot the exclusion set so has_outdated_files() can trigger a - # discovery reconfigure when it changes. Excluded components never - # register in project_description.json, so re-including one (e.g. a - # config gains mqtt) requires a fresh discovery pass before the - # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it. + # reconfigure when it changes. The ESPHOME_PROJECT_BUILTIN_COMPONENTS + # property drops excluded names, so re-including one (e.g. a config + # gains mqtt) needs a reconfigure to rebuild it from the discovery list. write_file_if_changed( CORE.relative_build_path("exclude_components.esphomeinternal"), ";".join(get_excluded_builtin_components()), diff --git a/esphome/build_gen/host.py b/esphome/build_gen/host.py new file mode 100644 index 0000000000..3a8587891b --- /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(ccache), + *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..c74262fdee --- /dev/null +++ b/esphome/build_helpers/ninja_gen.py @@ -0,0 +1,288 @@ +"""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(ccache: str | None) -> list[str]: + """The compile rules; ``$own_includes`` is empty unless an edge sets it. + + ``ccache`` is the launcher ``tool_lines`` was given; the same test here + keeps the variable and the rules in step. It is spliced in rather than + left as an empty variable: a leading space on the command line makes + CreateProcess fail on Windows. + """ + launcher = "$ccache " if ccache else "" + return [ + "rule c", + f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CC $out", + "rule cxx", + f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out", + " depfile = $out.d", + " deps = gcc", + " description = CXX $out", + "rule aspp", + f" command = {launcher}$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", + f" command = {launcher}$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], extra_flags: str = "" +) -> tuple[list[str], list[str]]: + """Emit every library's compile and archive edges. + + ``extra_flags`` (raw ninja text) precede each library's own flags. + + 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(filter(None, [extra_flags, *map(_shell_token, 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/bundle.py b/esphome/bundle.py index b633c5ca4f..f499e0de1b 100644 --- a/esphome/bundle.py +++ b/esphome/bundle.py @@ -102,25 +102,21 @@ _KNOWN_FILE_EXTENSIONS = frozenset( ) -# Matches !secret references in YAML text. An optional surrounding -# quote pair around the key is allowed and ignored: YAML treats -# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is -# intentionally a simple regex scan rather than a YAML parse — it may -# match inside comments or multi-line strings, which is the conservative -# direction (include more secrets rather than fewer). -_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""") - - def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]: """Scan YAML files for ``!secret `` references.""" keys: set[str] = set() for fpath in yaml_files: try: text = fpath.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + except (OSError, UnicodeDecodeError) as err: + _LOGGER.warning( + "Could not scan %s for !secret references (%s); the bundled " + "secret set may be incomplete", + fpath, + err, + ) continue - for match in _SECRET_RE.finditer(text): - keys.add(match.group(1)) + keys |= yaml_util.find_secret_references(text) return keys @@ -422,6 +418,18 @@ class ConfigBundleCreator: must ship every candidate so the remote build can pick any one. """ discovered = yaml_util.discover_user_yaml_files(self._config_path) + if discovered.load_errors: + _LOGGER.warning( + "Bundle may be incomplete; could not load all configuration files: %s", + "; ".join(discovered.load_errors), + ) + if discovered.unresolved: + _LOGGER.warning( + "Bundle may be incomplete; %d !include path(s) use " + "substitutions and cannot be captured: %s", + len(discovered.unresolved), + ", ".join(discovered.unresolved), + ) self._secrets_paths.update(discovered.secrets) # A !secret inside a file this re-parse does not reach (for example # a git-fetched package the builder re-fetches) still resolves 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..9ed36de0d5 100644 --- a/esphome/codegen.py +++ b/esphome/codegen.py @@ -31,17 +31,23 @@ 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_static_pointer, is_template, new_Pvariable, new_variable, process_lambda, progmem_array, + progmem_string, safe_exp, set_cpp_standard, + shared_progmem_array, + shared_progmem_string, statement, static_const_array, + static_function, templatable, variable, with_local_variable, @@ -64,7 +70,6 @@ from esphome.cpp_types import ( # noqa: F401 Application, Component, ComponentPtr, - Controller, EntityBase, EntityCategory, ESPTime, diff --git a/esphome/components/a01nyub/__init__.py b/esphome/components/a01nyub/__init__.py index 4c84847fb6..45206b66bc 100644 --- a/esphome/components/a01nyub/__init__.py +++ b/esphome/components/a01nyub/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@MrSuicideParrot"] +DOMAIN = "a01nyub" diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp index 6111af2b7e..8c97169cbf 100644 --- a/esphome/components/a01nyub/a01nyub.cpp +++ b/esphome/components/a01nyub/a01nyub.cpp @@ -6,7 +6,7 @@ namespace esphome::a01nyub { -static const char *const TAG = "a01nyub.sensor"; +ESPHOME_LOG_TAG(TAG, "a01nyub.sensor"); void A01nyubComponent::loop() { uint8_t data; diff --git a/esphome/components/a02yyuw/__init__.py b/esphome/components/a02yyuw/__init__.py index 6724dbb970..f6a28dfa18 100644 --- a/esphome/components/a02yyuw/__init__.py +++ b/esphome/components/a02yyuw/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@TH-Braemer"] +DOMAIN = "a02yyuw" diff --git a/esphome/components/a02yyuw/a02yyuw.cpp b/esphome/components/a02yyuw/a02yyuw.cpp index 2832334ef1..20e8f6c7b5 100644 --- a/esphome/components/a02yyuw/a02yyuw.cpp +++ b/esphome/components/a02yyuw/a02yyuw.cpp @@ -6,7 +6,7 @@ namespace esphome::a02yyuw { -static const char *const TAG = "a02yyuw.sensor"; +ESPHOME_LOG_TAG(TAG, "a02yyuw.sensor"); void A02yyuwComponent::loop() { uint8_t data; diff --git a/esphome/components/a4988/__init__.py b/esphome/components/a4988/__init__.py index e69de29bb2..55797725d6 100644 --- a/esphome/components/a4988/__init__.py +++ b/esphome/components/a4988/__init__.py @@ -0,0 +1 @@ +DOMAIN = "a4988" diff --git a/esphome/components/a4988/a4988.cpp b/esphome/components/a4988/a4988.cpp index d8fc6752f3..4af9e8baef 100644 --- a/esphome/components/a4988/a4988.cpp +++ b/esphome/components/a4988/a4988.cpp @@ -3,7 +3,7 @@ namespace esphome::a4988 { -static const char *const TAG = "a4988.stepper"; +ESPHOME_LOG_TAG(TAG, "a4988.stepper"); void A4988::setup() { if (this->sleep_pin_ != nullptr) { diff --git a/esphome/components/absolute_humidity/__init__.py b/esphome/components/absolute_humidity/__init__.py index 8f113b48f6..5cfae55a40 100644 --- a/esphome/components/absolute_humidity/__init__.py +++ b/esphome/components/absolute_humidity/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@DAVe3283"] +DOMAIN = "absolute_humidity" diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index 3137a59fad..36b6534c0d 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -3,7 +3,7 @@ namespace esphome::absolute_humidity { -static const char *const TAG{"absolute_humidity.sensor"}; +ESPHOME_LOG_TAG(TAG, "absolute_humidity.sensor"); void AbsoluteHumidityComponent::setup() { this->temperature_sensor_->add_on_state_callback([this](float state) { diff --git a/esphome/components/ac_dimmer/__init__.py b/esphome/components/ac_dimmer/__init__.py index e69de29bb2..7025ecff72 100644 --- a/esphome/components/ac_dimmer/__init__.py +++ b/esphome/components/ac_dimmer/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ac_dimmer" diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp index 477962a040..e126a3d52a 100644 --- a/esphome/components/ac_dimmer/ac_dimmer.cpp +++ b/esphome/components/ac_dimmer/ac_dimmer.cpp @@ -14,7 +14,7 @@ namespace esphome::ac_dimmer { -static const char *const TAG = "ac_dimmer"; +ESPHOME_LOG_TAG(TAG, "ac_dimmer"); // Global array to store dimmer objects static AcDimmerDataStore *all_dimmers[32]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp b/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp index 543b476085..fd26e6bccd 100644 --- a/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp +++ b/esphome/components/ac_dimmer/hw_timer_esp_idf.cpp @@ -9,7 +9,7 @@ #include "esp_clk_tree.h" #include "soc/clk_tree_defs.h" -static const char *const TAG = "hw_timer_esp_idf"; +ESPHOME_LOG_TAG(TAG, "hw_timer_esp_idf"); namespace esphome::ac_dimmer { diff --git a/esphome/components/adalight/__init__.py b/esphome/components/adalight/__init__.py index afdfefaba6..caf66167b1 100644 --- a/esphome/components/adalight/__init__.py +++ b/esphome/components/adalight/__init__.py @@ -9,6 +9,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType DEPENDENCIES = ["uart"] +DOMAIN = "adalight" adalight_ns = cg.esphome_ns.namespace("adalight") AdalightLightEffect = adalight_ns.class_( diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp index bf6849acaf..e3510d380e 100644 --- a/esphome/components/adalight/adalight_light_effect.cpp +++ b/esphome/components/adalight/adalight_light_effect.cpp @@ -3,7 +3,7 @@ namespace esphome::adalight { -static const char *const TAG = "adalight_light_effect"; +ESPHOME_LOG_TAG(TAG, "adalight_light_effect"); static const uint32_t ADALIGHT_ACK_INTERVAL = 1000; static const uint32_t ADALIGHT_RECEIVE_TIMEOUT = 1000; diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index c397e746b0..6966a86f6b 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -22,6 +22,7 @@ from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "adc" adc_ns = cg.esphome_ns.namespace("adc") diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h index 46b7e7a2ff..f813087645 100644 --- a/esphome/components/adc/adc_sensor.h +++ b/esphome/components/adc/adc_sensor.h @@ -128,10 +128,10 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v #endif // USE_RP2 protected: - uint8_t sample_count_{1}; - bool output_raw_{false}; + uint8_t sample_count_{1}; // Must match DEFAULT_SAMPLES in sensor.py + bool output_raw_{false}; // Must match CONF_RAW default in sensor.py InternalGPIOPin *pin_; - SamplingMode sampling_mode_{SamplingMode::AVG}; + SamplingMode sampling_mode_{SamplingMode::AVG}; // Must match DEFAULT_SAMPLING_MODE in sensor.py #ifdef USE_ESP32 float sample_autorange_(); @@ -139,7 +139,7 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v bool autorange_{false}; adc_oneshot_unit_handle_t adc_handle_{nullptr}; adc_cali_handle_t calibration_handle_{nullptr}; - adc_atten_t attenuation_{ADC_ATTEN_DB_0}; + adc_atten_t attenuation_{ADC_ATTEN_DB_0}; // Must match DEFAULT_ATTENUATION in sensor.py adc_channel_t channel_{}; adc_unit_t adc_unit_{}; struct SetupFlags { diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp index 70211000c3..dcb4138201 100644 --- a/esphome/components/adc/adc_sensor_common.cpp +++ b/esphome/components/adc/adc_sensor_common.cpp @@ -3,7 +3,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); const LogString *sampling_mode_to_str(SamplingMode mode) { switch (mode) { diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index c9887cea7c..6bab6fc469 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr}; diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp index 77a192e025..db231f7598 100644 --- a/esphome/components/adc/adc_sensor_esp8266.cpp +++ b/esphome/components/adc/adc_sensor_esp8266.cpp @@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC) namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp index dfa545b395..2ea9da935f 100644 --- a/esphome/components/adc/adc_sensor_libretiny.cpp +++ b/esphome/components/adc/adc_sensor_libretiny.cpp @@ -5,7 +5,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); void ADCSensor::setup() { #ifndef USE_ADC_SENSOR_VCC diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index ce665e8501..5756ac038a 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -17,7 +17,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp index bf45059740..c2f4aedd49 100644 --- a/esphome/components/adc/adc_sensor_zephyr.cpp +++ b/esphome/components/adc/adc_sensor_zephyr.cpp @@ -7,7 +7,7 @@ namespace esphome::adc { -static const char *const TAG = "adc"; +ESPHOME_LOG_TAG(TAG, "adc"); void ADCSensor::setup() { if (!adc_is_ready_dt(this->channel_)) { diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 8cdea4f01a..b44d88d56e 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -48,6 +48,11 @@ AUTO_LOAD = ["voltage_sampler"] CONF_SAMPLES = "samples" CONF_SAMPLING_MODE = "sampling_mode" +# Keep in sync with the initializers in adc_sensor.h +DEFAULT_ATTENUATION = "0db" +DEFAULT_SAMPLING_MODE = "avg" +DEFAULT_SAMPLES = 1 + _attenuation = cv.enum(ATTENUATION_MODES, lower=True) _sampling_mode = cv.enum(SAMPLING_MODES, lower=True) @@ -65,7 +70,10 @@ def validate_config(config: ConfigType) -> ConfigType: ): raise cv.Invalid("ESP32-S31 only supports 'attenuation: 0db'") - if config.get(CONF_ATTENUATION, None) == "auto" and config.get(CONF_SAMPLES, 1) > 1: + if ( + config.get(CONF_ATTENUATION, None) == "auto" + and config.get(CONF_SAMPLES, DEFAULT_SAMPLES) > 1 + ): raise cv.Invalid( "Automatic attenuation cannot be used when multisampling is set" ) @@ -113,12 +121,16 @@ CONFIG_SCHEMA = cv.All( { cv.Required(CONF_PIN): validate_adc_pin, cv.Optional(CONF_RAW, default=False): cv.boolean, - cv.SplitDefault(CONF_ATTENUATION, esp32="0db"): cv.All( + cv.SplitDefault(CONF_ATTENUATION, esp32=DEFAULT_ATTENUATION): cv.All( cv.only_on_esp32, _attenuation ), cv.OnlyWith(CONF_NRF_SAADC, PLATFORM_NRF52): cv.declare_id(adc_dt_spec), - cv.Optional(CONF_SAMPLES, default=1): cv.int_range(min=1, max=255), - cv.Optional(CONF_SAMPLING_MODE, default="avg"): _sampling_mode, + cv.Optional(CONF_SAMPLES, default=DEFAULT_SAMPLES): cv.int_range( + min=1, max=255 + ), + cv.Optional( + CONF_SAMPLING_MODE, default=DEFAULT_SAMPLING_MODE + ): _sampling_mode, } ) .extend(cv.polling_component_schema("60s")), @@ -155,9 +167,13 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - cg.add(var.set_output_raw(config[CONF_RAW])) - cg.add(var.set_sample_count(config[CONF_SAMPLES])) - cg.add(var.set_sampling_mode(config[CONF_SAMPLING_MODE])) + # Skip the setters when the config matches the C++ initializers. + if config[CONF_RAW]: + cg.add(var.set_output_raw(True)) + if (samples := config[CONF_SAMPLES]) != DEFAULT_SAMPLES: + cg.add(var.set_sample_count(samples)) + if (sampling_mode := config[CONF_SAMPLING_MODE]) != DEFAULT_SAMPLING_MODE: + cg.add(var.set_sampling_mode(sampling_mode)) if CORE.is_esp32: # Re-enable ESP-IDF's ADC driver (excluded by default to save compile time) @@ -166,7 +182,7 @@ async def to_code(config: ConfigType) -> None: if attenuation := config.get(CONF_ATTENUATION): if attenuation == "auto": cg.add(var.set_autorange(cg.global_ns.true)) - else: + elif attenuation != DEFAULT_ATTENUATION: cg.add(var.set_attenuation(attenuation)) variant = get_esp32_variant() diff --git a/esphome/components/adc128s102/__init__.py b/esphome/components/adc128s102/__init__.py index 684147752d..125b4113c5 100644 --- a/esphome/components/adc128s102/__init__.py +++ b/esphome/components/adc128s102/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["spi"] +DOMAIN = "adc128s102" MULTI_CONF = True CODEOWNERS = ["@DeerMaximum"] diff --git a/esphome/components/adc128s102/adc128s102.cpp b/esphome/components/adc128s102/adc128s102.cpp index ef0db4730a..bbe95d03be 100644 --- a/esphome/components/adc128s102/adc128s102.cpp +++ b/esphome/components/adc128s102/adc128s102.cpp @@ -3,7 +3,7 @@ namespace esphome::adc128s102 { -static const char *const TAG = "adc128s102"; +ESPHOME_LOG_TAG(TAG, "adc128s102"); float ADC128S102::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp b/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp index 01b6a3f2b8..3cc3f0c056 100644 --- a/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp +++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::adc128s102 { -static const char *const TAG = "adc128s102.sensor"; +ESPHOME_LOG_TAG(TAG, "adc128s102.sensor"); ADC128S102Sensor::ADC128S102Sensor(uint8_t channel) : channel_(channel) {} diff --git a/esphome/components/addressable_light/__init__.py b/esphome/components/addressable_light/__init__.py index e69de29bb2..28a33c66d8 100644 --- a/esphome/components/addressable_light/__init__.py +++ b/esphome/components/addressable_light/__init__.py @@ -0,0 +1 @@ +DOMAIN = "addressable_light" diff --git a/esphome/components/addressable_light/addressable_light_display.cpp b/esphome/components/addressable_light/addressable_light_display.cpp index 4cbcb3324b..6a3df025e6 100644 --- a/esphome/components/addressable_light/addressable_light_display.cpp +++ b/esphome/components/addressable_light/addressable_light_display.cpp @@ -3,7 +3,7 @@ namespace esphome::addressable_light { -static const char *const TAG = "addressable_light.display"; +ESPHOME_LOG_TAG(TAG, "addressable_light.display"); int AddressableLightDisplay::get_width_internal() { return this->width_; } int AddressableLightDisplay::get_height_internal() { return this->height_; } diff --git a/esphome/components/ade7880/__init__.py b/esphome/components/ade7880/__init__.py index e69de29bb2..f3dd325f5c 100644 --- a/esphome/components/ade7880/__init__.py +++ b/esphome/components/ade7880/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ade7880" diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp index 0f4189ad90..e131980ebb 100644 --- a/esphome/components/ade7880/ade7880.cpp +++ b/esphome/components/ade7880/ade7880.cpp @@ -15,7 +15,7 @@ namespace esphome::ade7880 { -static const char *const TAG = "ade7880"; +ESPHOME_LOG_TAG(TAG, "ade7880"); void IRAM_ATTR ADE7880Store::gpio_intr(ADE7880Store *arg) { arg->reset_done = true; } diff --git a/esphome/components/ade7953/__init__.py b/esphome/components/ade7953/__init__.py index d3078a0b67..512ac1a3f3 100644 --- a/esphome/components/ade7953/__init__.py +++ b/esphome/components/ade7953/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@angelnu"] +DOMAIN = "ade7953" diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py index 71250ac94e..808de99984 100644 --- a/esphome/components/ade7953_base/__init__.py +++ b/esphome/components/ade7953_base/__init__.py @@ -27,6 +27,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@angelnu"] +DOMAIN = "ade7953_base" CONF_CURRENT_A = "current_a" CONF_CURRENT_B = "current_b" diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp index 1adf44f8f7..21a16c5081 100644 --- a/esphome/components/ade7953_base/ade7953_base.cpp +++ b/esphome/components/ade7953_base/ade7953_base.cpp @@ -5,7 +5,7 @@ namespace esphome::ade7953_base { -static const char *const TAG = "ade7953"; +ESPHOME_LOG_TAG(TAG, "ade7953"); constexpr uint16_t CONFIG_DEFAULT = 0x8004u; constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u; diff --git a/esphome/components/ade7953_i2c/__init__.py b/esphome/components/ade7953_i2c/__init__.py index d3078a0b67..691ceb0274 100644 --- a/esphome/components/ade7953_i2c/__init__.py +++ b/esphome/components/ade7953_i2c/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@angelnu"] +DOMAIN = "ade7953_i2c" diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.cpp b/esphome/components/ade7953_i2c/ade7953_i2c.cpp index 252e55ee5c..cc0fb7e80d 100644 --- a/esphome/components/ade7953_i2c/ade7953_i2c.cpp +++ b/esphome/components/ade7953_i2c/ade7953_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::ade7953_i2c { -static const char *const TAG = "ade7953"; +ESPHOME_LOG_TAG(TAG, "ade7953"); void AdE7953I2c::dump_config() { ESP_LOGCONFIG(TAG, "ADE7953_i2c:"); diff --git a/esphome/components/ade7953_spi/__init__.py b/esphome/components/ade7953_spi/__init__.py index d3078a0b67..71ad260db2 100644 --- a/esphome/components/ade7953_spi/__init__.py +++ b/esphome/components/ade7953_spi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@angelnu"] +DOMAIN = "ade7953_spi" diff --git a/esphome/components/ade7953_spi/ade7953_spi.cpp b/esphome/components/ade7953_spi/ade7953_spi.cpp index c2d85231d6..e7c7bac97f 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.cpp +++ b/esphome/components/ade7953_spi/ade7953_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ade7953_spi { -static const char *const TAG = "ade7953"; +ESPHOME_LOG_TAG(TAG, "ade7953"); // Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2; diff --git a/esphome/components/ads1115/__init__.py b/esphome/components/ads1115/__init__.py index b42ee918c5..1b8405322f 100644 --- a/esphome/components/ads1115/__init__.py +++ b/esphome/components/ads1115/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] +DOMAIN = "ads1115" MULTI_CONF = True ads1115_ns = cg.esphome_ns.namespace("ads1115") diff --git a/esphome/components/ads1115/ads1115.cpp b/esphome/components/ads1115/ads1115.cpp index 58704bf7c6..b75e1b7304 100644 --- a/esphome/components/ads1115/ads1115.cpp +++ b/esphome/components/ads1115/ads1115.cpp @@ -4,7 +4,7 @@ namespace esphome::ads1115 { -static const char *const TAG = "ads1115"; +ESPHOME_LOG_TAG(TAG, "ads1115"); static const uint8_t ADS1115_REGISTER_CONVERSION = 0x00; static const uint8_t ADS1115_REGISTER_CONFIG = 0x01; diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.cpp b/esphome/components/ads1115/sensor/ads1115_sensor.cpp index 8086d97231..776346171b 100644 --- a/esphome/components/ads1115/sensor/ads1115_sensor.cpp +++ b/esphome/components/ads1115/sensor/ads1115_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ads1115 { -static const char *const TAG = "ads1115.sensor"; +ESPHOME_LOG_TAG(TAG, "ads1115.sensor"); float ADS1115Sensor::sample() { return this->parent_->request_measurement(this->multiplexer_, this->gain_, this->resolution_, this->samplerate_); diff --git a/esphome/components/ads1118/__init__.py b/esphome/components/ads1118/__init__.py index 956b9a0c1f..98129ecbea 100644 --- a/esphome/components/ads1118/__init__.py +++ b/esphome/components/ads1118/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@solomondg1"] DEPENDENCIES = ["spi"] +DOMAIN = "ads1118" MULTI_CONF = True CONF_ADS1118_ID = "ads1118_id" diff --git a/esphome/components/ads1118/ads1118.cpp b/esphome/components/ads1118/ads1118.cpp index 0a07193bfe..c21dc2d3f0 100644 --- a/esphome/components/ads1118/ads1118.cpp +++ b/esphome/components/ads1118/ads1118.cpp @@ -4,7 +4,7 @@ namespace esphome::ads1118 { -static const char *const TAG = "ads1118"; +ESPHOME_LOG_TAG(TAG, "ads1118"); static const uint8_t ADS1118_DATA_RATE_860_SPS = 0b111; void ADS1118::setup() { diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.cpp b/esphome/components/ads1118/sensor/ads1118_sensor.cpp index 383a3d25fc..2b3d68d1f4 100644 --- a/esphome/components/ads1118/sensor/ads1118_sensor.cpp +++ b/esphome/components/ads1118/sensor/ads1118_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ads1118 { -static const char *const TAG = "ads1118.sensor"; +ESPHOME_LOG_TAG(TAG, "ads1118.sensor"); void ADS1118Sensor::dump_config() { LOG_SENSOR(" ", "ADS1118 Sensor", this); diff --git a/esphome/components/ags10/__init__.py b/esphome/components/ags10/__init__.py index 37f34d06df..8d2bbd6fb4 100644 --- a/esphome/components/ags10/__init__.py +++ b/esphome/components/ags10/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@mak-42"] +DOMAIN = "ags10" diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp index 230548ae94..2db39a206d 100644 --- a/esphome/components/ags10/ags10.cpp +++ b/esphome/components/ags10/ags10.cpp @@ -4,7 +4,7 @@ #include namespace esphome::ags10 { -static const char *const TAG = "ags10"; +ESPHOME_LOG_TAG(TAG, "ags10"); // Data acquisition. static const uint8_t REG_TVOC = 0x00; @@ -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/__init__.py b/esphome/components/aht10/__init__.py index e69de29bb2..888d442fdf 100644 --- a/esphome/components/aht10/__init__.py +++ b/esphome/components/aht10/__init__.py @@ -0,0 +1 @@ +DOMAIN = "aht10" diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index cc90abfc3a..83ab55e657 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -19,7 +19,7 @@ namespace esphome::aht10 { -static const char *const TAG = "aht10"; +ESPHOME_LOG_TAG(TAG, "aht10"); static const uint8_t AHT10_INITIALIZE_CMD[] = {0xE1, 0x08, 0x00}; static const uint8_t AHT20_INITIALIZE_CMD[] = {0xBE, 0x08, 0x00}; static const uint8_t AHT10_MEASURE_CMD[] = {0xAC, 0x33, 0x00}; 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/__init__.py b/esphome/components/aic3204/__init__.py index e69de29bb2..c7f7f00960 100644 --- a/esphome/components/aic3204/__init__.py +++ b/esphome/components/aic3204/__init__.py @@ -0,0 +1 @@ +DOMAIN = "aic3204" diff --git a/esphome/components/aic3204/aic3204.cpp b/esphome/components/aic3204/aic3204.cpp index 0ba960fd70..892a6c56ac 100644 --- a/esphome/components/aic3204/aic3204.cpp +++ b/esphome/components/aic3204/aic3204.cpp @@ -6,7 +6,7 @@ namespace esphome::aic3204 { -static const char *const TAG = "aic3204"; +ESPHOME_LOG_TAG(TAG, "aic3204"); #define ERROR_CHECK(err, msg) \ if (!(err)) { \ 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_ble/__init__.py b/esphome/components/airthings_ble/__init__.py index 44534b80e9..48cebad5fb 100644 --- a/esphome/components/airthings_ble/__init__.py +++ b/esphome/components/airthings_ble/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeromelaban"] +DOMAIN = "airthings_ble" airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble") AirthingsListener = airthings_ble_ns.class_( diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp index f2625a7832..5f0e2eb3ec 100644 --- a/esphome/components/airthings_ble/airthings_listener.cpp +++ b/esphome/components/airthings_ble/airthings_listener.cpp @@ -4,7 +4,7 @@ namespace esphome::airthings_ble { -static const char *const TAG = "airthings_ble"; +ESPHOME_LOG_TAG(TAG, "airthings_ble"); bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) { for (auto &it : device.get_manufacturer_datas()) { diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py index 58fde11a3d..035142193a 100644 --- a/esphome/components/airthings_wave_base/__init__.py +++ b/esphome/components/airthings_wave_base/__init__.py @@ -24,6 +24,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@ncareau", "@jeromelaban"] +DOMAIN = "airthings_wave_base" DEPENDENCIES = ["ble_client"] @@ -85,20 +86,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_base/airthings_wave_base.cpp b/esphome/components/airthings_wave_base/airthings_wave_base.cpp index 5fa59f22fd..f4dfaaee32 100644 --- a/esphome/components/airthings_wave_base/airthings_wave_base.cpp +++ b/esphome/components/airthings_wave_base/airthings_wave_base.cpp @@ -8,7 +8,10 @@ namespace esphome::airthings_wave_base { -static const char *const TAG = "airthings_wave_base"; +ESPHOME_LOG_TAG(TAG, "airthings_wave_base"); + +static constexpr uint32_t BATTERY_READ_TIMEOUT_ID = 0; +static constexpr uint32_t RESPONSE_TIMEOUT_ID = 1; void AirthingsWaveBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { @@ -181,7 +184,7 @@ void AirthingsWaveBase::read_battery_(uint8_t *raw_value, uint16_t value_len) { // read the battery again at the configured update interval if (this->battery_update_interval_ != this->update_interval_) { this->read_battery_next_update_ = false; - this->set_timeout("battery", this->battery_update_interval_, + this->set_timeout(BATTERY_READ_TIMEOUT_ID, this->battery_update_interval_, [this]() { this->read_battery_next_update_ = true; }); } } @@ -204,7 +207,7 @@ void AirthingsWaveBase::response_received_() { } void AirthingsWaveBase::set_response_timeout_() { - this->set_timeout("response_timeout", 30 * 1000, [this]() { + this->set_timeout(RESPONSE_TIMEOUT_ID, 30 * 1000, [this]() { this->responses_pending_ = 1; this->response_received_(); }); diff --git a/esphome/components/airthings_wave_mini/__init__.py b/esphome/components/airthings_wave_mini/__init__.py index 022f35b4cf..64513a448d 100644 --- a/esphome/components/airthings_wave_mini/__init__.py +++ b/esphome/components/airthings_wave_mini/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ncareau"] +DOMAIN = "airthings_wave_mini" diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp b/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp index f487e9dbc0..54dbc21a6d 100644 --- a/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp +++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp @@ -4,7 +4,7 @@ namespace esphome::airthings_wave_mini { -static const char *const TAG = "airthings_wave_mini"; +ESPHOME_LOG_TAG(TAG, "airthings_wave_mini"); void AirthingsWaveMini::read_sensors(uint8_t *raw_value, uint16_t value_len) { auto *value = (WaveMiniReadings *) raw_value; diff --git a/esphome/components/airthings_wave_plus/__init__.py b/esphome/components/airthings_wave_plus/__init__.py index e26bfd471b..904aa922ba 100644 --- a/esphome/components/airthings_wave_plus/__init__.py +++ b/esphome/components/airthings_wave_plus/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jeromelaban", "@precurse"] +DOMAIN = "airthings_wave_plus" diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp index 80fe081b57..4b23cb685c 100644 --- a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp +++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp @@ -4,7 +4,7 @@ namespace esphome::airthings_wave_plus { -static const char *const TAG = "airthings_wave_plus"; +ESPHOME_LOG_TAG(TAG, "airthings_wave_plus"); void AirthingsWavePlus::read_sensors(uint8_t *raw_value, uint16_t value_len) { auto *value = (WavePlusReadings *) raw_value; 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 001ecd8861..ac12835433 100644 --- a/esphome/components/alarm_control_panel/__init__.py +++ b/esphome/components/alarm_control_panel/__init__.py @@ -21,6 +21,7 @@ from esphome.core.entity_helpers import ( from esphome.cpp_generator import MockObjClass CODEOWNERS = ["@grahambrown11", "@hwstar"] +DOMAIN = "alarm_control_panel" IS_PLATFORM_COMPONENT = True CONF_ON_TRIGGERED = "on_triggered" @@ -41,14 +42,6 @@ StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder") StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder") AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState") -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) @@ -208,65 +201,22 @@ for _name, _arm in ( ) -@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 +for _name, _call in ( + ("alarm_control_panel.pending", "pending()"), + ("alarm_control_panel.triggered", "triggered()"), ): - 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(_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.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp index fc72c13ce3..6f23b1aca8 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp @@ -10,7 +10,7 @@ namespace esphome::alarm_control_panel { -static const char *const TAG = "alarm_control_panel"; +ESPHOME_LOG_TAG(TAG, "alarm_control_panel"); AlarmControlPanelCall AlarmControlPanel::make_call() { return AlarmControlPanelCall(this); } diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h index aced89b7ff..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; diff --git a/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp b/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp index 0c43cd555a..667cae42a0 100644 --- a/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp +++ b/esphome/components/alarm_control_panel/alarm_control_panel_call.cpp @@ -6,7 +6,7 @@ namespace esphome::alarm_control_panel { -static const char *const TAG = "alarm_control_panel"; +ESPHOME_LOG_TAG(TAG, "alarm_control_panel"); AlarmControlPanelCall::AlarmControlPanelCall(AlarmControlPanel *parent) : parent_(parent) {} diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h index cd9b5eadce..86bdac37ec 100644 --- a/esphome/components/alarm_control_panel/automation.h +++ b/esphome/components/alarm_control_panel/automation.h @@ -27,36 +27,4 @@ static_assert(std::is_trivially_copyable_v); static_assert(sizeof(StateEnterForwarder) <= sizeof(void *)); static_assert(std::is_trivially_copyable_v>); -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/__init__.py b/esphome/components/alpha3/__init__.py index 7cd320c80f..551edbeb7f 100644 --- a/esphome/components/alpha3/__init__.py +++ b/esphome/components/alpha3/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jan-hofmeier"] +DOMAIN = "alpha3" diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp index 92b00d87cb..ce81ca4025 100644 --- a/esphome/components/alpha3/alpha3.cpp +++ b/esphome/components/alpha3/alpha3.cpp @@ -7,7 +7,7 @@ namespace esphome::alpha3 { -static const char *const TAG = "alpha3"; +ESPHOME_LOG_TAG(TAG, "alpha3"); void Alpha3::dump_config() { ESP_LOGCONFIG(TAG, "ALPHA3"); 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/__init__.py b/esphome/components/am2315c/__init__.py index 2398e4fa23..a0ffa96f5e 100644 --- a/esphome/components/am2315c/__init__.py +++ b/esphome/components/am2315c/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@swoboda1337"] +DOMAIN = "am2315c" diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp index 8980a8dfc3..d34061889b 100644 --- a/esphome/components/am2315c/am2315c.cpp +++ b/esphome/components/am2315c/am2315c.cpp @@ -26,7 +26,7 @@ namespace esphome::am2315c { -static const char *const TAG = "am2315c"; +ESPHOME_LOG_TAG(TAG, "am2315c"); bool AM2315C::reset_register_(uint8_t reg) { // code based on demo code sent by www.aosong.com 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/__init__.py b/esphome/components/am2320/__init__.py index e69de29bb2..34e57d220c 100644 --- a/esphome/components/am2320/__init__.py +++ b/esphome/components/am2320/__init__.py @@ -0,0 +1 @@ +DOMAIN = "am2320" diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp index 5445ab3898..94053ab5dc 100644 --- a/esphome/components/am2320/am2320.cpp +++ b/esphome/components/am2320/am2320.cpp @@ -10,7 +10,7 @@ namespace esphome::am2320 { -static const char *const TAG = "am2320"; +ESPHOME_LOG_TAG(TAG, "am2320"); void AM2320Component::update() { uint8_t data[8]; 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/__init__.py b/esphome/components/am43/__init__.py index f21a15ce0a..fa3caae506 100644 --- a/esphome/components/am43/__init__.py +++ b/esphome/components/am43/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@buxtronix"] +DOMAIN = "am43" diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp index 4b096983a4..6fb8c0c7eb 100644 --- a/esphome/components/am43/cover/am43_cover.cpp +++ b/esphome/components/am43/cover/am43_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::am43 { -static const char *const TAG = "am43_cover"; +ESPHOME_LOG_TAG(TAG, "am43_cover"); using namespace esphome::cover; 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/am43/sensor/am43_sensor.cpp b/esphome/components/am43/sensor/am43_sensor.cpp index ddc3eadae9..1a30af03ae 100644 --- a/esphome/components/am43/sensor/am43_sensor.cpp +++ b/esphome/components/am43/sensor/am43_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::am43 { -static const char *const TAG = "am43"; +ESPHOME_LOG_TAG(TAG, "am43"); void Am43::dump_config() { ESP_LOGCONFIG(TAG, "AM43"); diff --git a/esphome/components/analog_threshold/__init__.py b/esphome/components/analog_threshold/__init__.py index 9ae2df986d..c01f3b40e5 100644 --- a/esphome/components/analog_threshold/__init__.py +++ b/esphome/components/analog_threshold/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ianchi"] +DOMAIN = "analog_threshold" diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp index d25c10021c..c5061365a2 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::analog_threshold { -static const char *const TAG = "analog_threshold.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "analog_threshold.binary_sensor"); void AnalogThresholdBinarySensor::setup() { float sensor_value = this->sensor_->get_state(); 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/anova/__init__.py b/esphome/components/anova/__init__.py index e69de29bb2..4fdd8de72c 100644 --- a/esphome/components/anova/__init__.py +++ b/esphome/components/anova/__init__.py @@ -0,0 +1 @@ +DOMAIN = "anova" diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index b0769bb622..28e2da93e9 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -5,7 +5,7 @@ namespace esphome::anova { -static const char *const TAG = "anova"; +ESPHOME_LOG_TAG(TAG, "anova"); using namespace esphome::climate; diff --git a/esphome/components/apc1/__init__.py b/esphome/components/apc1/__init__.py new file mode 100644 index 0000000000..355ecc2305 --- /dev/null +++ b/esphome/components/apc1/__init__.py @@ -0,0 +1,3 @@ +"""Support for ScioSense APC1 air quality sensor.""" + +DOMAIN = "apc1" diff --git a/esphome/components/apc1/apc1.cpp b/esphome/components/apc1/apc1.cpp new file mode 100644 index 0000000000..beca9d8581 --- /dev/null +++ b/esphome/components/apc1/apc1.cpp @@ -0,0 +1,352 @@ +#include "apc1.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/progmem.h" + +#include +#include + +namespace esphome::apc1 { + +ESPHOME_LOG_TAG(TAG, "apc1"); + +static constexpr uint8_t APC1_MEASUREMENT_OFFSET_AQI = 58; +static constexpr uint8_t APC1_MEASUREMENT_OFFSET_ERROR_CODE = 61; + +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_MODULE = 4; +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_SERIAL = 10; +static constexpr uint8_t APC1_DEVICE_INFO_OFFSET_FW = 19; + +// Give a freshly powered sensor time to boot before the first retry +static constexpr uint32_t APC1_COLD_BOOT_INIT_DELAY_MS = 2000; +// Re-send the init commands at most this often while no frames arrive +static constexpr uint32_t APC1_INIT_RETRY_INTERVAL_MS = 10000; +// Active mode streams a frame every second; this long without one means the sensor stopped +static constexpr uint32_t APC1_NO_DATA_TIMEOUT_MS = 15000; +// Drop a partial frame when the bytes stop mid-way +static constexpr uint32_t APC1_RX_IDLE_TIMEOUT_MS = 200; + +// Frames and commands end with the 16-bit sum of every byte before it +static uint16_t apc1_checksum(const uint8_t *data, size_t len) { + uint16_t sum = 0; + for (size_t i = 0; i < len; i++) { + sum += data[i]; + } + return sum; +} + +void APC1Component::setup() { + if (this->set_pin_ != nullptr) { + this->set_pin_->setup(); + this->set_pin_->digital_write(true); + } + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + } + + // Attempt initial communication immediately for warm restarts + this->initialize_device_(); + + // Schedule delayed initialization to catch sensors during cold power-on bootup + this->set_timeout(APC1_COLD_BOOT_INIT_DELAY_MS, [this]() { this->initialize_device_(); }); +} + +void APC1Component::initialize_device_() { + this->last_init_attempt_ = App.get_loop_component_start_time(); + if (this->active_mode_ && !this->idle_) { + this->set_active_mode(); + } + this->send_command_(APC1Command::APC1_COMMAND_READ_SENSOR_VERSION, 0x0000); +} + +void APC1Component::dump_config() { + ESP_LOGCONFIG(TAG, "APC1:"); + LOG_PIN(" Set Pin: ", this->set_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_SENSOR(" ", "PM1.0", this->pm_1_0_sensor_); + LOG_SENSOR(" ", "PM2.5", this->pm_2_5_sensor_); + LOG_SENSOR(" ", "PM10.0", this->pm_10_0_sensor_); + LOG_SENSOR(" ", "PM1.0 Standard", this->pm_1_0_std_sensor_); + LOG_SENSOR(" ", "PM2.5 Standard", this->pm_2_5_std_sensor_); + LOG_SENSOR(" ", "PM10.0 Standard", this->pm_10_0_std_sensor_); + LOG_SENSOR(" ", "PM >0.3um", this->pm_0_3um_sensor_); + LOG_SENSOR(" ", "PM >0.5um", this->pm_0_5um_sensor_); + LOG_SENSOR(" ", "PM >1.0um", this->pm_1_0um_sensor_); + LOG_SENSOR(" ", "PM >2.5um", this->pm_2_5um_sensor_); + LOG_SENSOR(" ", "PM >5.0um", this->pm_5_0um_sensor_); + LOG_SENSOR(" ", "PM >10.0um", this->pm_10_0um_sensor_); + LOG_SENSOR(" ", "TVOC", this->tvoc_sensor_); + LOG_SENSOR(" ", "eCO2", this->eco2_sensor_); + LOG_SENSOR(" ", "AQI", this->aqi_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Raw Temperature", this->raw_temperature_sensor_); + LOG_SENSOR(" ", "Raw Humidity", this->raw_humidity_sensor_); + LOG_SENSOR(" ", "RS0", this->rs0_sensor_); + LOG_SENSOR(" ", "RS2", this->rs2_sensor_); + LOG_SENSOR(" ", "RS3", this->rs3_sensor_); + LOG_SENSOR(" ", "Error Code", this->error_code_sensor_); +} + +void APC1Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + + // If in active mode and not idle, retry periodically if no valid data received + if (this->active_mode_ && !this->idle_) { + if (this->last_valid_frame_ == 0) { + if (now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) { + ESP_LOGD(TAG, "Waiting for APC1 sensor to respond, sending init commands..."); + this->initialize_device_(); + } + } else if (now - this->last_valid_frame_ > APC1_NO_DATA_TIMEOUT_MS && + now - this->last_init_attempt_ >= APC1_INIT_RETRY_INTERVAL_MS) { + ESP_LOGW(TAG, "No data received from APC1 for %" PRIu32 " ms, re-initializing...", now - this->last_valid_frame_); + this->initialize_device_(); + } + } + + if (this->rx_index_ > 0 && now - this->last_transmission_ > APC1_RX_IDLE_TIMEOUT_MS) { + ESP_LOGV(TAG, "Timed out waiting for data, resetting buffer"); + this->rx_index_ = 0; + } + + while (this->available()) { + this->last_transmission_ = now; + uint8_t byte; + this->read_byte(&byte); + + if (this->rx_index_ == 0) { + if (byte == APC1_HEADER_1) { + this->rx_buffer_[this->rx_index_++] = byte; + } + continue; + } + + if (this->rx_index_ == 1) { + if (byte == APC1_HEADER_2) { + this->rx_buffer_[this->rx_index_++] = byte; + } else if (byte != APC1_HEADER_1) { + this->rx_index_ = 0; + } + continue; + } + + this->rx_buffer_[this->rx_index_++] = byte; + if (this->rx_index_ < APC1_FRAME_HEADER_SIZE) { + continue; + } + + // The payload carries at least the checksum and must fit the buffer; checking it before the sum keeps a + // corrupt length from wrapping the total and walking past the buffer + const uint16_t payload_len = encode_uint16(this->rx_buffer_[2], this->rx_buffer_[3]); + if (payload_len < 2 || payload_len > this->rx_buffer_.size() - APC1_FRAME_HEADER_SIZE) { + ESP_LOGW(TAG, "Invalid frame length: %u", payload_len); + this->rx_index_ = 0; + continue; + } + const uint8_t total_len = payload_len + APC1_FRAME_HEADER_SIZE; + if (this->rx_index_ == total_len) { + this->parse_frame_(total_len); + this->rx_index_ = 0; + } + } +} + +void APC1Component::parse_frame_(uint16_t total_len) { + uint16_t expected_checksum = encode_uint16(this->rx_buffer_[total_len - 2], this->rx_buffer_[total_len - 1]); + uint16_t calculated_checksum = apc1_checksum(this->rx_buffer_.data(), total_len - 2); + if (expected_checksum != calculated_checksum) { + ESP_LOGW(TAG, "APC1 checksum mismatch: calculated 0x%04X != expected 0x%04X", calculated_checksum, + expected_checksum); + return; + } + + if (total_len == APC1_FRAME_SIZE_MEASUREMENT) { + this->parse_measurement_frame_(); + } else if (total_len == APC1_FRAME_SIZE_DEVICE_INFO) { + this->parse_device_info_frame_(); + } else if (total_len == APC1_FRAME_SIZE_COMMAND_RESPONSE) { + ESP_LOGD(TAG, "APC1 command response: cmd=0x%02X, data=0x%02X", this->rx_buffer_[4], this->rx_buffer_[5]); + } else { + ESP_LOGD(TAG, "Received frame of unknown length %u", total_len); + } +} + +// One message per error bit, bit 0 first +PROGMEM_STRING_TABLE(ErrorStrings, "Fan restart error (too many restarts)", "Fan speed low", "Photodiode fault", + "Fan stopped / startup error", "Laser fault", "VOC sensor fault", "RHT sensor fault"); + +void APC1Component::parse_measurement_frame_() { + const uint32_t now = App.get_loop_component_start_time(); + this->last_valid_frame_ = now; + + uint8_t error_code = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_ERROR_CODE]; + if (error_code != this->last_error_code_) { + if (error_code != 0) { + for (uint8_t bit = 0; bit < ErrorStrings::COUNT; bit++) { + if (error_code & (1 << bit)) { + ESP_LOGW(TAG, "APC1: %s", LOG_STR_ARG(ErrorStrings::get_log_str(bit, ErrorStrings::LAST_INDEX))); + } + } + this->status_set_warning(LOG_STR("Sensor reported hardware error")); + } else { + ESP_LOGI(TAG, "APC1: Hardware error cleared"); + this->status_clear_warning(); + } + this->last_error_code_ = error_code; + if (this->error_code_sensor_ != nullptr) { + this->error_code_sensor_->publish_state(error_code); + } + } + + uint8_t aqi = this->rx_buffer_[APC1_MEASUREMENT_OFFSET_AQI]; + if (aqi == 0) { + if (!this->gas_warming_up_) { + this->gas_warming_up_ = true; + ESP_LOGI(TAG, "APC1: Gas sensor warming up (AQI=0)"); + } + } else if (this->gas_warming_up_) { + this->gas_warming_up_ = false; + ESP_LOGI(TAG, "APC1: Gas sensor warm-up complete (AQI=%u)", aqi); + } + + // Measurement payload data (bytes 4-57): big-endian sensor readings. + // The values below feed the debug summary, so they are always decoded; every + // other reading is only decoded when its sensor is configured. + const auto u16 = [this](uint8_t offset) { + return encode_uint16(this->rx_buffer_[offset], this->rx_buffer_[offset + 1]); + }; + const auto u32 = [this](uint8_t offset) { + return encode_uint32(this->rx_buffer_[offset], this->rx_buffer_[offset + 1], this->rx_buffer_[offset + 2], + this->rx_buffer_[offset + 3]); + }; + uint16_t pm_1_0 = u16(10); + uint16_t pm_2_5 = u16(12); + uint16_t pm_10_0 = u16(14); + uint16_t tvoc = u16(28); + uint16_t eco2 = u16(30); + float temp_comp = static_cast(u16(34)) / 10.0f; + float hum_comp = u16(36) / 10.0f; + + ESP_LOGD(TAG, "APC1: PM1.0: %u, PM2.5: %u, PM10: %u, TVOC: %u, eCO2: %u, AQI: %u, Temp: %.1f°C, Hum: %.1f%%", pm_1_0, + pm_2_5, pm_10_0, tvoc, eco2, aqi, temp_comp, hum_comp); + + if (this->pm_1_0_std_sensor_ != nullptr) + this->pm_1_0_std_sensor_->publish_state(u16(4)); + if (this->pm_2_5_std_sensor_ != nullptr) + this->pm_2_5_std_sensor_->publish_state(u16(6)); + if (this->pm_10_0_std_sensor_ != nullptr) + this->pm_10_0_std_sensor_->publish_state(u16(8)); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0); + + if (this->pm_0_3um_sensor_ != nullptr) + this->pm_0_3um_sensor_->publish_state(u16(16)); + if (this->pm_0_5um_sensor_ != nullptr) + this->pm_0_5um_sensor_->publish_state(u16(18)); + if (this->pm_1_0um_sensor_ != nullptr) + this->pm_1_0um_sensor_->publish_state(u16(20)); + if (this->pm_2_5um_sensor_ != nullptr) + this->pm_2_5um_sensor_->publish_state(u16(22)); + if (this->pm_5_0um_sensor_ != nullptr) + this->pm_5_0um_sensor_->publish_state(u16(24)); + if (this->pm_10_0um_sensor_ != nullptr) + this->pm_10_0um_sensor_->publish_state(u16(26)); + + if (this->tvoc_sensor_ != nullptr) + this->tvoc_sensor_->publish_state(tvoc); + if (this->eco2_sensor_ != nullptr) + this->eco2_sensor_->publish_state(eco2); + if (this->aqi_sensor_ != nullptr) + this->aqi_sensor_->publish_state(aqi > 0 ? aqi : NAN); + + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temp_comp); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(hum_comp); + + if (this->raw_temperature_sensor_ != nullptr) { + this->raw_temperature_sensor_->publish_state(static_cast(u16(38)) / 10.0f); + } + if (this->raw_humidity_sensor_ != nullptr) + this->raw_humidity_sensor_->publish_state(u16(40) / 10.0f); + + if (this->rs0_sensor_ != nullptr) { + this->rs0_sensor_->publish_state(u32(42)); + } + if (this->rs2_sensor_ != nullptr) { + this->rs2_sensor_->publish_state(u32(50)); + } + if (this->rs3_sensor_ != nullptr) { + this->rs3_sensor_->publish_state(u32(54)); + } +} + +void APC1Component::parse_device_info_frame_() { + const uint8_t *serial = this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_SERIAL; + uint16_t fw_version = + encode_uint16(this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW], this->rx_buffer_[APC1_DEVICE_INFO_OFFSET_FW + 1]); + ESP_LOGI(TAG, "APC1: Module: %.6s, Serial: %08" PRIX32 "%08" PRIX32 ", Firmware: 0x%04X (%u)", + reinterpret_cast(this->rx_buffer_.data() + APC1_DEVICE_INFO_OFFSET_MODULE), + encode_uint32(serial[0], serial[1], serial[2], serial[3]), + encode_uint32(serial[4], serial[5], serial[6], serial[7]), fw_version, fw_version); +} + +void APC1Component::arm_watchdog_() { + this->last_valid_frame_ = App.get_loop_component_start_time(); + this->last_init_attempt_ = this->last_valid_frame_; +} + +void APC1Component::set_active_mode() { + if (!this->active_mode_) { + this->arm_watchdog_(); + } + this->active_mode_ = true; + this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_ACTIVE); +} + +void APC1Component::set_passive_mode() { + this->active_mode_ = false; + this->send_command_(APC1Command::APC1_COMMAND_MEASUREMENT_MODE, APC1_MEASUREMENT_MODE_PASSIVE); +} + +void APC1Component::request_measurement() { + this->send_command_(APC1Command::APC1_COMMAND_REQUEST_MEASUREMENT, 0x0000); +} + +void APC1Component::set_idle_mode() { + this->idle_ = true; + this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_IDLE); +} + +void APC1Component::set_measurement_mode() { + this->idle_ = false; + this->arm_watchdog_(); + this->send_command_(APC1Command::APC1_COMMAND_OPERATION_MODE, APC1_OPERATING_MODE_STANDARD); +} + +void APC1Component::send_command_(APC1Command cmd, uint16_t data) { + std::array buf{ + APC1_HEADER_1, + APC1_HEADER_2, + static_cast(cmd), + static_cast((data >> 8) & 0xFF), + static_cast(data & 0xFF), + 0, + 0, + }; + uint16_t checksum = apc1_checksum(buf.data(), 5); + buf[5] = static_cast((checksum >> 8) & 0xFF); + buf[6] = static_cast(checksum & 0xFF); + this->write_array(buf); +} + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/apc1.h b/esphome/components/apc1/apc1.h new file mode 100644 index 0000000000..850738ead6 --- /dev/null +++ b/esphome/components/apc1/apc1.h @@ -0,0 +1,99 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::apc1 { + +static constexpr uint8_t APC1_HEADER_1 = 0x42; +static constexpr uint8_t APC1_HEADER_2 = 0x4D; +static constexpr uint8_t APC1_FRAME_HEADER_SIZE = 4; // 2 start bytes (0x42, 0x4D) + 2 frame length bytes +static constexpr uint8_t APC1_FRAME_SIZE_MEASUREMENT = 64; +static constexpr uint8_t APC1_FRAME_SIZE_DEVICE_INFO = 23; +static constexpr uint8_t APC1_FRAME_SIZE_COMMAND_RESPONSE = 8; +static constexpr uint8_t APC1_COMMAND_FRAME_SIZE = 7; + +enum class APC1Command : uint8_t { + APC1_COMMAND_MEASUREMENT_MODE = 0xE1, + APC1_COMMAND_REQUEST_MEASUREMENT = 0xE2, + APC1_COMMAND_OPERATION_MODE = 0xE4, + APC1_COMMAND_READ_SENSOR_VERSION = 0xE9, +}; + +static constexpr uint16_t APC1_MEASUREMENT_MODE_PASSIVE = 0x0000; +static constexpr uint16_t APC1_MEASUREMENT_MODE_ACTIVE = 0x0001; + +static constexpr uint16_t APC1_OPERATING_MODE_IDLE = 0x0000; +static constexpr uint16_t APC1_OPERATING_MODE_STANDARD = 0x0001; + +class APC1Component final : public uart::UARTDevice, public Component { + SUB_SENSOR(pm_1_0) + SUB_SENSOR(pm_2_5) + SUB_SENSOR(pm_10_0) + SUB_SENSOR(pm_1_0_std) + SUB_SENSOR(pm_2_5_std) + SUB_SENSOR(pm_10_0_std) + + SUB_SENSOR(pm_0_3um) + SUB_SENSOR(pm_0_5um) + SUB_SENSOR(pm_1_0um) + SUB_SENSOR(pm_2_5um) + SUB_SENSOR(pm_5_0um) + SUB_SENSOR(pm_10_0um) + + SUB_SENSOR(tvoc) + SUB_SENSOR(eco2) + SUB_SENSOR(aqi) + + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(raw_temperature) + SUB_SENSOR(raw_humidity) + + SUB_SENSOR(rs0) + SUB_SENSOR(rs2) + SUB_SENSOR(rs3) + + SUB_SENSOR(error_code) + + public: + void setup() override; + void dump_config() override; + void loop() override; + + void set_active_mode(); + void set_passive_mode(); + void request_measurement(); + void set_idle_mode(); + void set_measurement_mode(); + + void set_set_pin(GPIOPin *set_pin) { this->set_pin_ = set_pin; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + + protected: + void send_command_(APC1Command cmd, uint16_t data); + void initialize_device_(); + /// Restarts the no-data watchdog so a mode change gets the same grace period as a cold boot + void arm_watchdog_(); + void parse_frame_(uint16_t total_len); + void parse_measurement_frame_(); + void parse_device_info_frame_(); + + GPIOPin *set_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + uint32_t last_transmission_{0}; + uint32_t last_valid_frame_{0}; + uint32_t last_init_attempt_{0}; + std::array rx_buffer_{}; + uint8_t rx_index_{0}; + uint8_t last_error_code_{0xFF}; + bool active_mode_{true}; + bool idle_{false}; + bool gas_warming_up_{false}; +}; + +} // namespace esphome::apc1 diff --git a/esphome/components/apc1/sensor.py b/esphome/components/apc1/sensor.py new file mode 100644 index 0000000000..b64a49903f --- /dev/null +++ b/esphome/components/apc1/sensor.py @@ -0,0 +1,222 @@ +from esphome import automation, pins +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import sensor, uart +from esphome.components.const import CONF_AQI +import esphome.config_validation as cv +from esphome.const import ( + CONF_ECO2, + CONF_HUMIDITY, + CONF_ID, + CONF_PM_0_3UM, + CONF_PM_0_5UM, + CONF_PM_1_0, + CONF_PM_1_0_STD, + CONF_PM_1_0UM, + CONF_PM_2_5, + CONF_PM_2_5_STD, + CONF_PM_2_5UM, + CONF_PM_5_0UM, + CONF_PM_10_0, + CONF_PM_10_0_STD, + CONF_PM_10_0UM, + CONF_RESET_PIN, + CONF_TEMPERATURE, + CONF_TVOC, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM1, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_CHEMICAL_WEAPON, + ICON_MOLECULE_CO2, + ICON_OMEGA, + ICON_RADIATOR, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_COUNT_DECILITRE, + UNIT_MICROGRAMS_PER_CUBIC_METER, + UNIT_OHM, + UNIT_PARTS_PER_BILLION, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@ademuri"] +DEPENDENCIES = ["uart"] + +CONF_SET_PIN = "set_pin" +CONF_RAW_TEMPERATURE = "raw_temperature" +CONF_RAW_HUMIDITY = "raw_humidity" +CONF_RS0 = "rs0" +CONF_RS2 = "rs2" +CONF_RS3 = "rs3" +CONF_ERROR_CODE = "error_code" + +apc1_ns = cg.esphome_ns.namespace("apc1") +APC1Component = apc1_ns.class_("APC1Component", uart.UARTDevice, cg.Component) + + +def _pm_mass_schema(device_class: str) -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_MICROGRAMS_PER_CUBIC_METER, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=device_class, + state_class=STATE_CLASS_MEASUREMENT, + ) + + +_PM_COUNT_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNT_DECILITRE, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) +_TEMPERATURE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, +) +_HUMIDITY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, +) +_RESISTANCE_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_OHM, + icon=ICON_OMEGA, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(APC1Component), + cv.Optional(CONF_PM_1_0): _pm_mass_schema(DEVICE_CLASS_PM1), + cv.Optional(CONF_PM_2_5): _pm_mass_schema(DEVICE_CLASS_PM25), + cv.Optional(CONF_PM_10_0): _pm_mass_schema(DEVICE_CLASS_PM10), + cv.Optional(CONF_PM_1_0_STD): _pm_mass_schema(DEVICE_CLASS_PM1), + cv.Optional(CONF_PM_2_5_STD): _pm_mass_schema(DEVICE_CLASS_PM25), + cv.Optional(CONF_PM_10_0_STD): _pm_mass_schema(DEVICE_CLASS_PM10), + cv.Optional(CONF_PM_0_3UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_0_5UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_1_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_2_5UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_5_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_PM_10_0UM): _PM_COUNT_SCHEMA, + cv.Optional(CONF_TVOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_ECO2): 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_AQI): sensor.sensor_schema( + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_TEMPERATURE): _TEMPERATURE_SCHEMA, + cv.Optional(CONF_HUMIDITY): _HUMIDITY_SCHEMA, + cv.Optional(CONF_RAW_TEMPERATURE): _TEMPERATURE_SCHEMA, + cv.Optional(CONF_RAW_HUMIDITY): _HUMIDITY_SCHEMA, + cv.Optional(CONF_RS0): _RESISTANCE_SCHEMA, + cv.Optional(CONF_RS2): _RESISTANCE_SCHEMA, + cv.Optional(CONF_RS3): _RESISTANCE_SCHEMA, + cv.Optional(CONF_ERROR_CODE): sensor.sensor_schema( + icon="mdi:alert-circle-outline", + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def final_validate(config: ConfigType) -> None: + schema = uart.final_validate_device_schema( + "apc1", baud_rate=9600, require_rx=True, require_tx=True + ) + schema(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) + + 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) + 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_0_3UM, var.set_pm_0_3um_sensor) + await sensors(CONF_PM_0_5UM, var.set_pm_0_5um_sensor) + await sensors(CONF_PM_1_0UM, var.set_pm_1_0um_sensor) + await sensors(CONF_PM_2_5UM, var.set_pm_2_5um_sensor) + await sensors(CONF_PM_5_0UM, var.set_pm_5_0um_sensor) + await sensors(CONF_PM_10_0UM, var.set_pm_10_0um_sensor) + await sensors(CONF_TVOC, var.set_tvoc_sensor) + await sensors(CONF_ECO2, var.set_eco2_sensor) + await sensors(CONF_AQI, var.set_aqi_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_HUMIDITY, var.set_humidity_sensor) + await sensors(CONF_RAW_TEMPERATURE, var.set_raw_temperature_sensor) + await sensors(CONF_RAW_HUMIDITY, var.set_raw_humidity_sensor) + await sensors(CONF_RS0, var.set_rs0_sensor) + await sensors(CONF_RS2, var.set_rs2_sensor) + await sensors(CONF_RS3, var.set_rs3_sensor) + await sensors(CONF_ERROR_CODE, var.set_error_code_sensor) + + if (set_pin_config := config.get(CONF_SET_PIN)) is not None: + set_pin = await cg.gpio_pin_expression(set_pin_config) + cg.add(var.set_set_pin(set_pin)) + + if (reset_pin_config := config.get(CONF_RESET_PIN)) is not None: + reset_pin = await cg.gpio_pin_expression(reset_pin_config) + cg.add(var.set_reset_pin(reset_pin)) + + +APC1_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(): cv.use_id(APC1Component), + } +) + + +for _name, _call in ( + ("apc1.set_active_mode", "set_active_mode()"), + ("apc1.set_passive_mode", "set_passive_mode()"), + ("apc1.request_measurement", "request_measurement()"), + ("apc1.set_idle_mode", "set_idle_mode()"), + ("apc1.set_measurement_mode", "set_measurement_mode()"), +): + automation.register_apply_action( + _name, APC1_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/apds9306/__init__.py b/esphome/components/apds9306/__init__.py index 3dc8fcf5ff..b22aecf6d2 100644 --- a/esphome/components/apds9306/__init__.py +++ b/esphome/components/apds9306/__init__.py @@ -2,3 +2,4 @@ # https://www.mouser.ca/datasheet/2/678/AVGO_S_A0002854364_1-2574547.pdf CODEOWNERS = ["@aodrenah"] +DOMAIN = "apds9306" diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp index 57a502ca42..daf6bb6b33 100644 --- a/esphome/components/apds9306/apds9306.cpp +++ b/esphome/components/apds9306/apds9306.cpp @@ -7,7 +7,7 @@ namespace esphome::apds9306 { -static const char *const TAG = "apds9306"; +ESPHOME_LOG_TAG(TAG, "apds9306"); enum { // APDS9306 registers APDS9306_MAIN_CTRL = 0x00, diff --git a/esphome/components/apds9930/__init__.py b/esphome/components/apds9930/__init__.py new file mode 100644 index 0000000000..0e6330bd88 --- /dev/null +++ b/esphome/components/apds9930/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@iyesin"] +DOMAIN = "apds9930" diff --git a/esphome/components/apds9930/apds9930.cpp b/esphome/components/apds9930/apds9930.cpp new file mode 100644 index 0000000000..6debc0d9c7 --- /dev/null +++ b/esphome/components/apds9930/apds9930.cpp @@ -0,0 +1,140 @@ +#include "apds9930.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::apds9930 { + +ESPHOME_LOG_TAG(TAG, "apds9930"); + +// Register addresses +static constexpr uint8_t APDS9930_ENABLE = 0x00; +static constexpr uint8_t APDS9930_ATIME = 0x01; +static constexpr uint8_t APDS9930_PTIME = 0x02; +static constexpr uint8_t APDS9930_PPULSE = 0x0E; +static constexpr uint8_t APDS9930_CONTROL = 0x0F; +static constexpr uint8_t APDS9930_ID = 0x12; +static constexpr uint8_t APDS9930_STATUS = 0x13; + +// Command byte: repeated byte and auto-increment protocols +static constexpr uint8_t APDS9930_CMD = 0x80; +static constexpr uint8_t APDS9930_CMD_AUTO_INCREMENT = 0xA0; + +static constexpr uint8_t APDS9930_CHIP_ID = 0x39; + +// ENABLE register bits +static constexpr uint8_t APDS9930_PON = 0x01; +static constexpr uint8_t APDS9930_AEN = 0x02; +static constexpr uint8_t APDS9930_PEN = 0x04; + +// STATUS register bits +static constexpr uint8_t APDS9930_AVALID = 0x01; +static constexpr uint8_t APDS9930_PVALID = 0x02; + +// CONTROL register fields +static constexpr uint8_t CONTROL_PDRIVE_SHIFT = 6; +static constexpr uint8_t CONTROL_PDIODE_SHIFT = 4; +static constexpr uint8_t CONTROL_PGAIN_SHIFT = 2; +static constexpr uint8_t CONTROL_AGAIN_SHIFT = 0; +static constexpr uint8_t CONTROL_PDIODE_CH1 = 0b10; // The only supported proximity diode + +static constexpr uint8_t APDS9930_ATIME_VALUE = 0xED; // 19 cycles, about 52 ms +static constexpr uint8_t APDS9930_PTIME_VALUE = 0xFF; // 2.73 ms +static constexpr uint8_t APDS9930_PPULSE_VALUE = 0x08; // 8 pulses + +// Lux calculation coefficients +static constexpr float APDS9930_DF = 52.0f; +static constexpr float APDS9930_GA = 0.49f; +static constexpr float APDS9930_ALS_B = 1.862f; +static constexpr float APDS9930_ALS_C = 0.746f; +static constexpr float APDS9930_ALS_D = 1.291f; +static constexpr float APDS9930_ALSIT_MS = 2.73f * (256 - APDS9930_ATIME_VALUE); + +static constexpr uint8_t AMBIENT_GAINS[4] PROGMEM = {1, 8, 16, 120}; +static constexpr uint8_t PROXIMITY_GAINS[4] PROGMEM = {1, 2, 4, 8}; +static constexpr float LED_CURRENT_MAX_MA = 100.0f; // halved for each drive step + +// Lux per count at 1x gain and the fixed integration time +static constexpr float LUX_PER_COUNT_1X = APDS9930_GA * APDS9930_DF / APDS9930_ALSIT_MS; +// Each integration cycle adds up to 1024 counts, so this is the ceiling of both light channels +static constexpr uint16_t ALS_SATURATION = 1024 * (256 - APDS9930_ATIME_VALUE); + +bool APDS9930Component::write_reg_(uint8_t reg, uint8_t value) { return this->write_byte(reg | APDS9930_CMD, value); } + +void APDS9930Component::setup() { + uint8_t id; + if (!this->read_byte(APDS9930_ID | APDS9930_CMD, &id)) { + this->mark_failed(LOG_STR("Communication failed")); + return; + } + if (id != APDS9930_CHIP_ID) { + ESP_LOGE(TAG, "Wrong ID 0x%02X (expected 0x%02X)", id, APDS9930_CHIP_ID); + this->mark_failed(LOG_STR("Wrong chip ID")); + return; + } + + const uint8_t control = + (this->led_drive_ & 0b11) << CONTROL_PDRIVE_SHIFT | CONTROL_PDIODE_CH1 << CONTROL_PDIODE_SHIFT | + (this->proximity_gain_ & 0b11) << CONTROL_PGAIN_SHIFT | (this->ambient_gain_ & 0b11) << CONTROL_AGAIN_SHIFT; + + uint8_t enable = APDS9930_PON; + if (this->illuminance_sensor_ != nullptr) + enable |= APDS9930_AEN; + if (this->proximity_sensor_ != nullptr) + enable |= APDS9930_PEN; + + if (!this->write_reg_(APDS9930_ENABLE, 0x00) || !this->write_reg_(APDS9930_ATIME, APDS9930_ATIME_VALUE) || + !this->write_reg_(APDS9930_PTIME, APDS9930_PTIME_VALUE) || + !this->write_reg_(APDS9930_PPULSE, APDS9930_PPULSE_VALUE) || !this->write_reg_(APDS9930_CONTROL, control) || + !this->write_reg_(APDS9930_ENABLE, enable)) { + this->mark_failed(LOG_STR("Configuration failed")); + } +} + +void APDS9930Component::dump_config() { + ESP_LOGCONFIG(TAG, + "APDS9930:\n" + " LED Drive: %.1f mA\n" + " Proximity Gain: %ux\n" + " Ambient Light Gain: %ux", + LED_CURRENT_MAX_MA / (1 << this->led_drive_), + progmem_read_byte(&PROXIMITY_GAINS[this->proximity_gain_]), + progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_])); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Proximity", this->proximity_sensor_); +} + +void APDS9930Component::update() { + // STATUS, CH0DATAL/H, CH1DATAL/H, PDATAL/H + uint8_t raw[7]; + if (!this->read_bytes(APDS9930_STATUS | APDS9930_CMD_AUTO_INCREMENT, raw, sizeof(raw))) { + this->status_set_warning(LOG_STR("Reading data failed")); + return; + } + this->status_clear_warning(); + const uint8_t status = raw[0]; + + if (this->illuminance_sensor_ != nullptr && (status & APDS9930_AVALID) != 0) { + const uint16_t ch0 = encode_uint16(raw[2], raw[1]); + const uint16_t ch1 = encode_uint16(raw[4], raw[3]); + if (ch0 >= ALS_SATURATION || ch1 >= ALS_SATURATION) { + // A saturated channel would give a lux value that is too low, not too high + ESP_LOGW(TAG, "Light channel saturated, lower ambient_light_gain"); + this->illuminance_sensor_->publish_state(NAN); + } else { + const float iac = std::max({ch0 - APDS9930_ALS_B * ch1, APDS9930_ALS_C * ch0 - APDS9930_ALS_D * ch1, 0.0f}); + this->illuminance_sensor_->publish_state(iac * LUX_PER_COUNT_1X / + progmem_read_byte(&AMBIENT_GAINS[this->ambient_gain_])); + } + } + + if (this->proximity_sensor_ != nullptr && (status & APDS9930_PVALID) != 0) { + this->proximity_sensor_->publish_state(encode_uint16(raw[6], raw[5])); + } +} + +} // namespace esphome::apds9930 diff --git a/esphome/components/apds9930/apds9930.h b/esphome/components/apds9930/apds9930.h new file mode 100644 index 0000000000..cc7f77d3d0 --- /dev/null +++ b/esphome/components/apds9930/apds9930.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::apds9930 { + +class APDS9930Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(illuminance) + SUB_SENSOR(proximity) + + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_led_drive(uint8_t level) { this->led_drive_ = level; } + void set_proximity_gain(uint8_t gain) { this->proximity_gain_ = gain; } + void set_ambient_gain(uint8_t gain) { this->ambient_gain_ = gain; } + + protected: + bool write_reg_(uint8_t reg, uint8_t value); + + uint8_t led_drive_{0}; + uint8_t proximity_gain_{0}; + uint8_t ambient_gain_{0}; +}; + +} // namespace esphome::apds9930 diff --git a/esphome/components/apds9930/sensor.py b/esphome/components/apds9930/sensor.py new file mode 100644 index 0000000000..2b169271c6 --- /dev/null +++ b/esphome/components/apds9930/sensor.py @@ -0,0 +1,70 @@ +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_ID, + CONF_ILLUMINANCE, + DEVICE_CLASS_ILLUMINANCE, + STATE_CLASS_MEASUREMENT, + UNIT_LUX, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +CONF_AMBIENT_LIGHT_GAIN = "ambient_light_gain" +CONF_LED_DRIVE = "led_drive" +CONF_PROXIMITY = "proximity" +CONF_PROXIMITY_GAIN = "proximity_gain" + +DRIVE_LEVELS = {"100ma": 0, "50ma": 1, "25ma": 2, "12.5ma": 3} +PROXIMITY_LEVELS = {"1x": 0, "2x": 1, "4x": 2, "8x": 3} +AMBIENT_LEVELS = {"1x": 0, "8x": 1, "16x": 2, "120x": 3} + +apds9930_ns = cg.esphome_ns.namespace("apds9930") +APDS9930Component = apds9930_ns.class_( + "APDS9930Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(APDS9930Component), + cv.Optional(CONF_LED_DRIVE, default="100ma"): cv.enum( + DRIVE_LEVELS, lower=True + ), + cv.Optional(CONF_PROXIMITY_GAIN, default="8x"): cv.enum( + PROXIMITY_LEVELS, lower=True + ), + cv.Optional(CONF_AMBIENT_LIGHT_GAIN, default="1x"): cv.enum( + AMBIENT_LEVELS, lower=True + ), + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PROXIMITY): sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x39)) +) + + +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_led_drive(config[CONF_LED_DRIVE])) + cg.add(var.set_proximity_gain(config[CONF_PROXIMITY_GAIN])) + cg.add(var.set_ambient_gain(config[CONF_AMBIENT_LIGHT_GAIN])) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ILLUMINANCE, var.set_illuminance_sensor) + await sensors(CONF_PROXIMITY, var.set_proximity_sensor) diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py index 7ac1e5eb32..8c19eec5ca 100644 --- a/esphome/components/apds9960/__init__.py +++ b/esphome/components/apds9960/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] +DOMAIN = "apds9960" MULTI_CONF = True CONF_APDS9960_ID = "apds9960_id" diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp index da8029b4ee..ef5d43e2cb 100644 --- a/esphome/components/apds9960/apds9960.cpp +++ b/esphome/components/apds9960/apds9960.cpp @@ -4,7 +4,7 @@ namespace esphome::apds9960 { -static const char *const TAG = "apds9960"; +ESPHOME_LOG_TAG(TAG, "apds9960"); #define APDS9960_ERROR_CHECK(func) \ if (!(func)) { \ diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 854bceecfa..9611228ba1 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,14 +6,16 @@ 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 +from esphome.components.network import DOMAIN as NETWORK_DOMAIN # ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external # components and downstream consumers that import them from api 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 +28,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 +52,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.cpp_generator import Expression, MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.helpers import 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 +90,11 @@ def AUTO_LOAD(config: ConfigType) -> list[str]: api_ns = cg.esphome_ns.namespace("api") -APIServer = api_ns.class_("APIServer", cg.Component, cg.Controller) +APIServer = api_ns.class_("APIServer", cg.Component) HomeAssistantServiceCallAction = api_ns.class_( "HomeAssistantServiceCallAction", automation.Action ) +HomeAssistantField = api_ns.struct("HomeAssistantField") ActionResponse = api_ns.class_("ActionResponse") HomeAssistantActionResponseTrigger = api_ns.class_( "HomeAssistantActionResponseTrigger", automation.Trigger @@ -134,6 +144,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" +CONF_OUTGOING_CONNECTION = "outgoing_connection" CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" # Schema defaults that also match the C++ initializers in api_server.h; codegen @@ -141,6 +152,7 @@ CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" DEFAULT_PORT = 6053 DEFAULT_REBOOT_TIMEOUT = "15min" DEFAULT_BATCH_DELAY = "100ms" +DEFAULT_LISTEN_BACKLOG = 4 def _register_provisioning_source(config: ConfigType) -> ConfigType: @@ -291,9 +303,72 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: # (not max_connections, which is the upper limit rarely reached) socket.consume_sockets(3, "api")(config) socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config) + if CONF_OUTGOING_CONNECTION in config: + socket.consume_sockets(1, "api_outgoing_connection")(config) return config +def _validate_outgoing_connection(config: ConfigType) -> ConfigType: + if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None: + return config + if CONF_ENCRYPTION not in config: + raise cv.Invalid( + "outgoing_connection requires 'encryption' so the peer is verified by key", + path=[CONF_OUTGOING_CONNECTION], + ) + # A device with no client reboots once reboot_timeout passes, so a delay that + # reaches it would reboot the device before it ever dials + reboot_timeout = config[CONF_REBOOT_TIMEOUT] + delay = outgoing[CONF_DELAY] + if reboot_timeout.total_milliseconds and delay >= reboot_timeout: + raise cv.Invalid( + f"delay must be shorter than reboot_timeout ({reboot_timeout}), " + "otherwise the device reboots before it dials", + path=[CONF_OUTGOING_CONNECTION, CONF_DELAY], + ) + return config + + +def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address: + """Only accept an address the device itself can parse. + + Python accepts a scope id, which neither `inet_pton` nor lwIP's `inet6_aton` + takes, and a v4-mapped address is dialed as plain IPv4, needing no IPv6 build. + """ + address = cv.ipaddress(value) + if isinstance(address, IPv6Address): + if address.scope_id is not None: + raise cv.Invalid( + f"{value} carries a scope id, which the device cannot parse; " + "give the address without the '%' part" + ) + if (mapped := address.ipv4_mapped) is not None: + return mapped + return address + + +_OUTGOING_CONNECTION_SCHEMA = cv.Schema( + { + cv.Optional(CONF_HOST): _validate_outgoing_host, + cv.Optional(CONF_PORT, default=6054): cv.port, + # Bounded against reboot_timeout in _validate_outgoing_connection + cv.Optional(CONF_DELAY, default="60s"): cv.positive_time_period_milliseconds, + } +) + + +@schema_extractor("schema") +def _outgoing_connection_schema(config: ConfigType | None) -> ConfigType: + # A bare `outgoing_connection:` block is valid; without a host the device + # dials the remembered last dial-back client + if config is SCHEMA_EXTRACT: + # Let the language-schema dumper walk host, port and delay + return _OUTGOING_CONNECTION_SCHEMA + if config is None: + config = {} + return _OUTGOING_CONNECTION_SCHEMA(config) + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -318,6 +393,8 @@ CONFIG_SCHEMA = cv.All( ): ACTIONS_SCHEMA, cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA, cv.Optional(CONF_ENCRYPTION): encryption_schema, + cv.Optional(wizard.CONF_WIZARD): wizard.WIZARD_SCHEMA, + cv.Optional(CONF_OUTGOING_CONNECTION): _outgoing_connection_schema, cv.Optional(CONF_BATCH_DELAY, default=DEFAULT_BATCH_DELAY): cv.All( cv.positive_time_period_milliseconds, cv.Range(max=cv.TimePeriod(milliseconds=65535)), @@ -373,6 +450,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), + _validate_outgoing_connection, _consume_api_sockets, _register_provisioning_source, ) @@ -429,31 +507,41 @@ 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_DOMAIN) 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 _add_action_strings( - index: int, strings: list[str | None], interned: dict[str, MockObj] -) -> MockObj: - """Emit the PROGMEM string table for one action. +def _validate_wizard(config: ConfigType) -> ConfigType: + wizard.final_validate(config) + return config - Each string is its own PROGMEM array because on ESP8266 .rodata is RAM, and identical - strings are shared between actions through `interned`. - """ - entries: list[MockObj] = [] - for string in strings: - if string is None: - entries.append(cg.nullptr) - continue - if (var := interned.get(string)) is None: - var = interned[string] = cg.progmem_array( - ID(f"api_action_str{len(interned)}", is_declaration=True, type=cg.char), - string, - ) - entries.append(var) + +FINAL_VALIDATE_SCHEMA = cv.All( + _validate_esp8266_action_strings, + _validate_outgoing_host_ipv6, + _validate_wizard, +) + + +def _add_action_strings(index: int, strings: list[str | None]) -> MockObj: + """Emit the PROGMEM string table for one action.""" return cg.progmem_array( ID(f"api_action{index}_strings", is_declaration=True, type=cg.const_char_ptr), - entries, + [cg.nullptr if s is None else cg.shared_progmem_string(s) for s in strings], ) @@ -462,8 +550,7 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) - # Track controller registration for StaticVector sizing - CORE.register_controller() + CORE.register_controller(var) # Request a log listener slot for API log streaming request_log_listener() @@ -477,8 +564,10 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_reboot_timeout(reboot_timeout)) if (batch_delay := config[CONF_BATCH_DELAY]) != cv.time_period(DEFAULT_BATCH_DELAY): cg.add(var.set_batch_delay(batch_delay)) - if CONF_LISTEN_BACKLOG in config: - cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) + if ( + listen_backlog := config.get(CONF_LISTEN_BACKLOG) + ) is not None and listen_backlog != DEFAULT_LISTEN_BACKLOG: + cg.add(var.set_listen_backlog(listen_backlog)) cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS]) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) @@ -505,7 +594,6 @@ async def to_code(config: ConfigType) -> None: has_metadata = _has_action_metadata(actions) if has_metadata: cg.add_define("USE_API_USER_DEFINED_ACTION_METADATA") - interned_strings: dict[str, MockObj] = {} # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.MockObj] = [] for index, conf in enumerate(actions): @@ -550,7 +638,7 @@ async def to_code(config: ConfigType) -> None: service_template_args.append(native) func_args.append((native, name)) strings = _action_strings(conf, has_metadata) - table = _add_action_strings(index, strings, interned_strings) + table = _add_action_strings(index, strings) if CORE.is_esp8266: scratch_size = max(scratch_size, _action_strings_size(strings)) # Template args: supports_response mode, then user service arg types @@ -576,13 +664,16 @@ async def to_code(config: ConfigType) -> None: unregister_id, var, ) - cg.add(auto.add_actions([unregister_action])) + cg.add(auto.add_action(unregister_action)) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) if CORE.is_esp8266 and has_user_actions: # Stack buffer that list-entities copies PROGMEM strings into, sized for the largest action cg.add_define("API_USER_ACTION_STRINGS_SCRATCH_SIZE", max(scratch_size, 1)) + if (wizard_config := config.get(wizard.CONF_WIZARD)) is not None: + await wizard.to_code(wizard_config) + if CONF_ON_CLIENT_CONNECTED in config: cg.add_define("USE_API_CLIENT_CONNECTED_TRIGGER") await automation.build_automation( @@ -601,7 +692,7 @@ async def to_code(config: ConfigType) -> None: if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None: if key := encryption_config.get(CONF_KEY): - cg.add(var.set_noise_psk(new_psk_progmem(config[CONF_ID], key))) + cg.add(var.set_noise_psk(new_psk_progmem(key))) cg.add_define("USE_API_NOISE_PSK_FROM_YAML") else: # No key provided, but encryption desired @@ -614,9 +705,17 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") + enable_spare_ephemeral() else: cg.add_define("USE_API_PLAINTEXT") + if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None: + cg.add_define("USE_API_OUTGOING_CONNECTION") + if (host := outgoing.get(CONF_HOST)) is not None: + cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host)) + cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT]) + cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY]) + cg.add_define("USE_API") cg.add_global(api_ns.using) @@ -657,6 +756,11 @@ VARIABLES_SCHEMA = cv.Schema( {cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))} ) +# The action stores each map's entry count in a uint8_t +_FIELD_MAP_MAX = 255 +DATA_FIELDS_SCHEMA = cv.All(KEY_VALUE_SCHEMA, cv.Length(max=_FIELD_MAP_MAX)) +VARIABLES_FIELDS_SCHEMA = cv.All(VARIABLES_SCHEMA, cv.Length(max=_FIELD_MAP_MAX)) + def _validate_response_config(config: ConfigType) -> ConfigType: # Validate dependencies: @@ -691,9 +795,9 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( cv.Exclusive(CONF_ACTION, group_of_exclusion=CONF_ACTION): cv.templatable( cv.string ), - cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, + cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA, cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string), cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean, cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), @@ -706,6 +810,54 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ) +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 cg.shared_progmem_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, cg.shared_progmem_string(value), cg.nullptr) + + entries = [await field(None, service, cg.std_string)] + for group in groups: + for key, value in group.items(): + entries.append(await field(key, value)) + table = cg.shared_progmem_array( + "ha_action_fields", + field_type, + cg.ArrayInitializer(*entries, multiline=True), + share=not has_lambda, + ) + return cg.new_Pvariable( + action_id, template_arg, serv, is_event, table, *(len(g) for g in groups) + ) + + +def _service_call_fields(config: ConfigType) -> tuple[dict[str, Any], ...]: + return config[CONF_DATA], config[CONF_DATA_TEMPLATE], config[CONF_VARIABLES] + + # synchronous=False: when on_success/on_error is configured, play() stores the # trigger args until the HomeassistantActionResponse arrives, so non-owning args # (StringRef into the API receive buffer) must not be used. @@ -727,36 +879,15 @@ async def homeassistant_service_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, False) - templ = await cg.templatable(config[CONF_ACTION], args, cg.std_string) - cg.add(var.set_service(templ)) - - # Initialize FixedVectors with exact sizes from config - cg.add(var.init_data(len(config[CONF_DATA]))) - for key, value in config[CONF_DATA].items(): - # output_type=None because lambdas can return non-string types (int, - # float, char*) that TemplatableStringValue converts via to_string. - # Static strings are manually wrapped for PROGMEM on ESP8266. - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE]))) - for key, value in config[CONF_DATA_TEMPLATE].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_variables(len(config[CONF_VARIABLES]))) - for key, value in config[CONF_VARIABLES].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) + var = await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + False, + config[CONF_ACTION], + *_service_call_fields(config), + ) if on_error := config.get(CONF_ON_ERROR): cg.add_define("USE_API_HOMEASSISTANT_ACTION_RESPONSES") @@ -808,9 +939,9 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(APIServer), cv.Required(CONF_EVENT): validate_homeassistant_event, - cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, + cv.Optional(CONF_DATA, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_DATA_TEMPLATE, default={}): DATA_FIELDS_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_FIELDS_SCHEMA, } ) @@ -829,38 +960,15 @@ async def homeassistant_event_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, True) - templ = await cg.templatable(config[CONF_EVENT], args, cg.std_string) - cg.add(var.set_service(templ)) - - # Initialize FixedVectors with exact sizes from config - cg.add(var.init_data(len(config[CONF_DATA]))) - for key, value in config[CONF_DATA].items(): - # output_type=None because lambdas can return non-string types (int, - # float, char*) that TemplatableStringValue converts via to_string. - # Static strings are manually wrapped for PROGMEM on ESP8266. - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_data_template(len(config[CONF_DATA_TEMPLATE]))) - for key, value in config[CONF_DATA_TEMPLATE].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_data_template(cg.FlashStringLiteral(key), templ)) - - cg.add(var.init_variables(len(config[CONF_VARIABLES]))) - for key, value in config[CONF_VARIABLES].items(): - templ = await cg.templatable(value, args, None) - if isinstance(templ, str): - templ = cg.FlashStringLiteral(templ) - cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) - - return var + return await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + True, + config[CONF_EVENT], + *_service_call_fields(config), + ) HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value( @@ -884,15 +992,17 @@ async def homeassistant_tag_scanned_to_code( template_arg: cg.TemplateArguments, args: TemplateArgsType, ) -> MockObj: - cg.add_define("USE_API_HOMEASSISTANT_SERVICES") - serv = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, serv, True) - cg.add(var.set_service(cg.FlashStringLiteral("esphome.tag_scanned"))) - # Initialize FixedVector with exact size (1 data field) - cg.add(var.init_data(1)) - templ = await cg.templatable(config[CONF_TAG], args, cg.std_string) - cg.add(var.add_data(cg.FlashStringLiteral("tag_id"), templ)) - return var + return await _new_service_call_action( + config[CONF_ID], + action_id, + template_arg, + args, + True, + "esphome.tag_scanned", + {"tag_id": config[CONF_TAG]}, + {}, + {}, + ) CONF_SUCCESS = "success" @@ -1003,6 +1113,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 21972decad..f8f31d81df 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,8 +78,11 @@ 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) {} + + rpc sendspin_pairing_token(SendspinPairingTokenRequest) returns (SendspinPairingTokenResponse) {} } @@ -113,6 +118,11 @@ message HelloRequest { string client_info = 1; uint32 api_version_major = 2; uint32 api_version_minor = 3; + + // Set by clients that can accept connections the device opens to them + // (see api: outgoing_connection:). The device remembers this client's + // address as the target to dial when no such client is connected. + bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"]; } // Confirmation of successful connection request. @@ -228,6 +238,7 @@ enum SerialProxyPortType { SERIAL_PROXY_PORT_TYPE_TTL = 0; SERIAL_PROXY_PORT_TYPE_RS232 = 1; SERIAL_PROXY_PORT_TYPE_RS485 = 2; + SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3; // since API 1.18 } message SerialProxyInfo { @@ -332,6 +343,10 @@ message DeviceInfoResponse { // all-zeros PSK, so the api encryption key can be provisioned without being // sent in plaintext (protects against passive sniffing, not active MITM) bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; + + // Device is built with the api outgoing_connection option and can open + // the TCP connection to a dial-back target itself + bool api_outgoing_connection_supported = 27 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"]; } // ==================== DEVICE CAPABILITIES ==================== @@ -380,6 +395,16 @@ 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 SendspinCapabilities { + // Bitmask of the Sendspin features this device supports + uint32 feature_flags = 1; +} + message DeviceCapabilitiesResponse { option (id) = 150; option (source) = SOURCE_SERVER; @@ -389,6 +414,102 @@ 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"]; + SendspinCapabilities sendspin = 6 [(field_ifdef) = "USE_SENDSPIN"]; +} + +// ==================== 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": "...", "multiple": true, "max_length": 1023, +// "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. +// +// An input with "multiple": true lets the user pick several entities. The client sends +// them in one WizardInputSetRequest as a comma separated list with no spaces +// ("calendar.a,calendar.b"); the device also accepts spaces around each id and stores the +// list without them. "max_length" (only on a multiple input, 255 when left out) is the +// longest list in bytes the device accepts. The version stays 1: a client that does not +// know "multiple" ignores it, shows a single picker and sends one entity id, which a +// multiple input accepts. +// +// 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"; + + // The data is in flash, and the device sends it from there + bytes data = 1 [(pointer_to_buffer) = true, (progmem) = 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. A single input is +// ignored when the entity_id is empty, longer than 255 bytes or has no '.'. A multiple +// input takes a comma separated list ("calendar.a,calendar.b"); the client sends no +// spaces, the device trims spaces around each id and stores the list without them. The +// whole request is ignored when any id is empty or has no '.', or when the list without +// spaces does not fit the input's max_length (255 by default). Otherwise it keeps the id +// or list 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) = 4095]; } message ListEntitiesRequest { @@ -652,7 +773,7 @@ message ListEntitiesLightResponse { bool legacy_supports_color_temperature = 8 [deprecated=true]; float min_mireds = 9; float max_mireds = 10; - repeated string effects = 11 [(container_pointer_no_template) = "FixedVector"]; + repeated string effects = 11 [(container_pointer_no_template) = "light::LightEffectNames"]; bool disabled_by_default = 13; string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63]; EntityCategory entity_category = 15; @@ -803,6 +924,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; @@ -1244,6 +1368,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; @@ -1330,6 +1457,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 @@ -1430,7 +1560,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"]; @@ -2440,7 +2570,7 @@ message ListEntitiesEventResponse { EntityCategory entity_category = 7; string device_class = 8 [(max_data_length) = 47]; - repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector"]; + repeated string event_types = 9 [(container_pointer_no_template) = "ConstVector"]; uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"]; } message EventResponse { @@ -2674,7 +2804,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 @@ -2688,7 +2818,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; @@ -2697,6 +2827,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 @@ -2867,6 +3019,56 @@ message SerialProxySetModeRequest { SerialProxyMode mode = 2; } +// Subscribe to the identity of every serial proxy port. The device answers with one +// SerialProxyIdentity per port, then sends another whenever a port's identity changes, +// for the life of the connection (since API 1.18). +message SubscribeSerialProxyIdentityRequest { + option (id) = 154; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SERIAL_PROXY"; +} + +// Where a port's identity comes from +enum SerialProxyIdentitySource { + SERIAL_PROXY_IDENTITY_SOURCE_NONE = 0; // The port carries no identity + SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED = 1; // Reserved for identities stated in the device configuration; not sent yet + SERIAL_PROXY_IDENTITY_SOURCE_USB = 2; // Read from the descriptors of the USB device behind the port; + // changes when a device is attached or removed +} + +enum SerialProxyIdentityFlag { + SERIAL_PROXY_IDENTITY_FLAG_NONE = 0; + SERIAL_PROXY_IDENTITY_FLAG_CONNECTED = 1; // The backend believes the device is present and usable on this port + SERIAL_PROXY_IDENTITY_FLAG_ERROR = 2; // The USB host stack refused the descriptor query; the strings and + // IDs below are empty +} + +// The descriptor fields of a USB device as seen by the host stack +message UsbDeviceDescriptor { + uint32 vendor_id = 1; + uint32 product_id = 2; + uint32 bcd_device = 3; + uint32 interface_number = 4; // bInterfaceNumber the host driver binds to +} + +// The identity of the device behind a port. Identifies one physical device among others of +// the same kind, so a client matches on manufacturer and product regardless of source. The +// strings are empty for source NONE, and for source USB while nothing is connected +// (since API 1.18). +message SerialProxyIdentity { + option (id) = 155; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SERIAL_PROXY"; + + uint32 instance = 1; + SerialProxyIdentitySource source = 2; + uint32 flags = 3; // Bitmask of SerialProxyIdentityFlag + string manufacturer = 4; + string product = 5; + string serial_number = 6; + UsbDeviceDescriptor usb = 7; // Only sent for source USB +} + // ==================== BLUETOOTH CONNECTION PARAMS ==================== message BluetoothSetConnectionParamsRequest { option (id) = 145; @@ -2888,3 +3090,34 @@ message BluetoothSetConnectionParamsResponse { uint64 address = 1; int32 error = 2; } + +// ==================== SENDSPIN ==================== +enum SendspinPairingTokenStatus { + // The Sendspin client has not started yet, so the token does not exist yet. Ask again later. + SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0; + SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1; + // The device has no API encryption key, so it will not send the token. Ask again once a key is set. + SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2; + // Sendspin is turned off and has not started since boot, so the token does not exist yet. + SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3; + // The Sendspin client failed to start, and will not start until the device reboots. + SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4; +} + +// Requests the Sendspin pairing token, which a Sendspin server uses to pair with this device +message SendspinPairingTokenRequest { + option (id) = 159; + option (source) = SOURCE_CLIENT; + option (ifdef) = "USE_SENDSPIN"; + // Empty +} + +message SendspinPairingTokenResponse { + option (id) = 160; + option (source) = SOURCE_SERVER; + option (ifdef) = "USE_SENDSPIN"; + + SendspinPairingTokenStatus status = 1; + // The pairing token (Sendspin pairing.md "Pairing Token"). Set only when status is OK. + string token = 2 [(max_data_length) = 127]; +} diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 3064ff09b1..fd51aae1be 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -48,6 +48,9 @@ #ifdef USE_ZWAVE_PROXY #include "esphome/components/zwave_proxy/zwave_proxy.h" #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) +#include "esphome/components/sendspin/sendspin_hub.h" +#endif #ifdef USE_WATER_HEATER #include "esphome/components/water_heater/water_heater.h" #endif @@ -88,7 +91,7 @@ static_assert(sizeof(ESPHOME_VERSION) - 1 <= 32, "Update max_data_length for esp static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for name in api.proto"); static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); -static const char *const TAG = "api.connection"; +ESPHOME_LOG_TAG(TAG, "api.connection"); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN void log_dropped_message(const char *tag, int line, const LogString *what) { @@ -198,6 +201,17 @@ APIConnection::~APIConnection() { proxy->serial_proxy_request(this, enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE); } } +#endif + // entities holding a transmit reply for this client must not answer into a freed connection +#ifdef USE_INFRARED + for (auto *infrared : App.get_infrareds()) { + infrared->on_api_connection_closed(this); + } +#endif +#ifdef USE_RADIO_FREQUENCY + for (auto *radio_frequency : App.get_radio_frequencies()) { + radio_frequency->on_api_connection_closed(this); + } #endif } @@ -600,7 +614,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); @@ -630,16 +644,7 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c msg.min_mireds = traits.get_min_mireds(); msg.max_mireds = traits.get_max_mireds(); } - FixedVector effects_list; - if (light->supports_effects()) { - auto &light_effects = light->get_effects(); - effects_list.init(light_effects.size() + 1); - effects_list.push_back("None"); - for (auto *effect : light_effects) { - // c_str() is safe as effect names are null-terminated strings from codegen - effects_list.push_back(effect->get_name().c_str()); - } - } + light::LightEffectNames effects_list(light->get_effects()); msg.effects = &effects_list; return fill_and_encode_entity_info(light, msg, conn, remaining_size); } @@ -709,6 +714,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); } @@ -754,6 +760,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 | @@ -988,7 +995,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) { @@ -1387,6 +1396,40 @@ void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { } #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) +bool APIConnection::send_sendspin_pairing_token_response_() { + SendspinPairingTokenResponse resp; +#ifdef USE_API_NOISE + // has_psk() stands in for "this session proved the key": with a key active, plaintext and zero-key sessions + // never reach hello, and activating a key closes every session opened before it. + if (!this->parent_->get_noise_ctx().has_psk()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED; + return this->send_message(resp); + } + auto token = sendspin_::global_sendspin_hub->get_pairing_token(); + if (token.has_value()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK; + resp.token = StringRef(*token); + } else if (sendspin_::global_sendspin_hub->status_has_error()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED; + } else if (sendspin_::global_sendspin_hub->is_disabled()) { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED; + } else { + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY; + } +#else + resp.status = enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED; +#endif + return this->send_message(resp); +} + +void APIConnection::on_sendspin_pairing_token_request() { + if (!this->send_sendspin_pairing_token_response_()) { + this->on_fatal_error(); + } +} +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool APIConnection::send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel) { return this->send_message_smart_(a_alarm_control_panel, AlarmControlPanelStateResponse::MESSAGE_TYPE, @@ -1447,6 +1490,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(); @@ -1512,8 +1556,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 @@ -1523,6 +1569,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; } @@ -1535,13 +1582,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 @@ -1549,6 +1621,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 @@ -1640,6 +1713,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()) { @@ -1811,7 +1900,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 17; + resp.api_version_minor = 19; // 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()); @@ -1834,6 +1923,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); } @@ -1956,6 +2058,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; @@ -2008,6 +2113,12 @@ 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 +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + resp.sendspin.feature_flags = sendspin_::global_sendspin_hub->get_feature_flags(); #endif return this->send_message(resp); } @@ -2222,7 +2333,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); @@ -2266,8 +2383,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) @@ -2660,6 +2781,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 c19a33ca9b..9f784185b8 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,6 +223,10 @@ class APIConnection final : public APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &msg); #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + void on_sendspin_pairing_token_request(); +#endif + #ifdef USE_ALARM_CONTROL_PANEL bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel); void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg); @@ -233,9 +237,12 @@ class APIConnection final : public APIServerConnectionBase { void on_water_heater_command_request(const WaterHeaterCommandRequest &msg); #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg); void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg); + // Reply to an InfraredRFTransmitRawTimingsRequest (API 1.18+); false when the TCP buffer is + // full, the entity that owns the reply retries it then + [[nodiscard]] bool send_infrared_rf_transmit_complete(uint32_t device_id, uint32_t key, bool success); #endif #ifdef USE_SERIAL_PROXY @@ -243,6 +250,9 @@ class APIConnection final : public APIServerConnectionBase { void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg); void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg); void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg); + void on_subscribe_serial_proxy_identity_request(); + /// Send a port identity to this client + void send_serial_proxy_identity(const SerialProxyIdentity &msg); void on_serial_proxy_request(const SerialProxyRequest &msg); void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg); void send_serial_proxy_data(const SerialProxyDataReceived &msg); @@ -273,6 +283,12 @@ class APIConnection final : public APIServerConnectionBase { void on_ping_request(); void on_device_info_request(); void on_device_capabilities_request(); +#ifdef USE_API_WIZARD + void on_device_wizard_request(); +#endif +#ifdef USE_API_WIZARD_INPUTS + void on_wizard_input_set_request(const WizardInputSetRequest &msg); +#endif void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -302,6 +318,10 @@ class APIConnection final : public APIServerConnectionBase { #endif #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(); +#ifdef USE_API_WIZARD_LINKED_INPUTS + /// Tell this client about the subscriptions whose entity id is stored in the given buffer, as the buffer changed + void resend_state_subscriptions(const char *entity_id); +#endif #endif #ifdef USE_API_USER_DEFINED_ACTIONS void on_execute_service_request(const ExecuteServiceRequest &msg); @@ -317,9 +337,15 @@ class APIConnection final : public APIServerConnectionBase { void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg); #endif + // How long a new connection holds off the spare ephemeral refill + static constexpr uint32_t CONNECT_GRACE_MS = 1000; bool is_authenticated() { return static_cast(this->flags_.connection_state) == ConnectionState::AUTHENTICATED; } + // An older unauthenticated connection is a stale half open client and does not count + bool is_still_connecting(uint32_t now) { + return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS; + } bool is_connection_setup() { return static_cast(this->flags_.connection_state) == ConnectionState::CONNECTED || this->is_authenticated(); @@ -339,18 +365,14 @@ class APIConnection final : public APIServerConnectionBase { void on_no_setup_connection(); // Function pointer type for type-erased message encoding - using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM); + using MessageEncodeFn = ProtoEncodeFn; // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we /// have no silent failures: every caller must handle (or log) a refusal. template [[nodiscard]] bool send_message(const T &msg) { - if constexpr (T::ESTIMATED_SIZE == 0) { - return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); - } else { - return this->send_message_(msg.calculate_size(), T::MESSAGE_TYPE, &proto_encode_msg, &msg); - } + return this->send_message_(T::calc_size_msg(&msg), T::MESSAGE_TYPE, &T::encode_msg, &msg); } /// Clear the shared write buffer and reserve space for the first message. @@ -376,6 +398,23 @@ class APIConnection final : public APIServerConnectionBase { return this->helper_->get_peername_to(buf); } +#ifdef USE_API_OUTGOING_CONNECTION + /// Get the peer address itself, for remembering a dial-back target + int getpeername(struct sockaddr *addr, socklen_t *addrlen) const { return this->helper_->getpeername(addr, addrlen); } + /// Outgoing connection: send our server hello immediately so the peer can + /// pick the matching key. Outgoing connections are only dialed when a PSK + /// is set, so the helper is always the noise helper. Call after start(). + void mark_outgoing() { + if (this->flags_.remove) { + return; // start() failed; the connection is already being torn down + } + APIError err = static_cast(this->helper_.get())->send_server_hello_first(); + if (err != APIError::OK) { + this->fatal_error_with_log_(LOG_STR("Server hello failed"), err); + } + } +#endif + protected: bool try_to_clear_buffer_slow_(bool log_out_of_space); @@ -397,6 +436,9 @@ class APIConnection final : public APIServerConnectionBase { #ifdef USE_VOICE_ASSISTANT bool send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg); #endif +#if defined(USE_SENDSPIN) && defined(USE_ESP_IDF) + bool send_sendspin_pairing_token_response_(); +#endif #ifdef USE_CAMERA void try_send_camera_image_(); @@ -406,16 +448,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); @@ -434,11 +466,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 @@ -450,7 +478,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 @@ -462,7 +490,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 @@ -476,8 +504,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 @@ -746,12 +774,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.cpp b/esphome/components/api/api_frame_helper.cpp index 41d1230aaa..de50eb109f 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -10,7 +10,7 @@ namespace esphome::api { -static const char *const TAG = "api.frame_helper"; +ESPHOME_LOG_TAG(TAG, "api.frame_helper"); // Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t API_MAX_LOG_BYTES = 168; 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 e726a7c967..47772fe590 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -22,7 +22,7 @@ using noise::noise_err_to_logstr; 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"; +ESPHOME_LOG_TAG(TAG, "api.noise"); static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; static constexpr size_t PROLOGUE_INIT_LEN = 12; // strlen("NoiseAPIInit") @@ -70,6 +70,13 @@ APIError APINoiseFrameHelper::init() { 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(); @@ -242,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_(); @@ -274,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); @@ -302,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_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index d4e3354fa0..dc00d8ec1e 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -16,7 +16,7 @@ namespace esphome::api { -static const char *const TAG = "api.plaintext"; +ESPHOME_LOG_TAG(TAG, "api.plaintext"); // Maximum bytes to log in hex format (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t API_MAX_LOG_BYTES = 168; diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto index 66295b3d53..8e8fa99833 100644 --- a/esphome/components/api/api_options.proto +++ b/esphome/components/api/api_options.proto @@ -122,4 +122,9 @@ extend google.protobuf.FieldOptions { // to true when the field arrives, so an all-default submessage can be told apart // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros). optional bool track_presence = 50020 [default=false]; + + // progmem: The pointer_to_buffer bytes field points into flash (PROGMEM). + // The generated encode and dump copy it with progmem_memcpy, as ESP8266 + // cannot read flash with ordinary loads. + optional bool progmem = 50021 [default=false]; } diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp new file mode 100644 index 0000000000..b38976e8cd --- /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 { + +ESPHOME_LOG_TAG(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 7f162d9c15..8847249b17 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -7,1370 +7,1408 @@ namespace esphome::api { -bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->api_version_major = value; +void HelloRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.client_info = StringRef(value.data(), value.size()); break; - case 3: - this->api_version_minor = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.api_version_major = value.as_varint(); break; - default: - return false; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.api_version_minor = value.as_varint(); + break; +#ifdef USE_API_OUTGOING_CONNECTION + case proto_tag(4, WIRE_TYPE_VARINT): + msg.outgoing_connection_target = value.as_bool(); + break; +#endif } - return true; } -bool HelloRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->client_info = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -uint8_t *HelloResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->api_version_major); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->api_version_minor); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->server_info); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->name); +uint8_t *HelloResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.api_version_major); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.api_version_minor); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.server_info); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.name); return pos; } -uint32_t HelloResponse::calculate_size() const { +uint32_t HelloResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->api_version_major); - size += ProtoSize::calc_uint32(1, this->api_version_minor); - size += 2 + this->server_info.size(); - size += 2 + this->name.size(); + size += ProtoSize::calc_uint32(1, msg.api_version_major); + size += ProtoSize::calc_uint32(1, msg.api_version_minor); + size += 2 + msg.server_info.size(); + size += 2 + msg.name.size(); return size; } -bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->reason = static_cast(value); +void DisconnectRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.reason = static_cast(value.as_varint()); break; - default: - return false; } - return true; } -uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason)); +uint8_t *DisconnectRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.reason)); return pos; } -uint32_t DisconnectRequest::calculate_size() const { +uint32_t DisconnectRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->reason ? 2 : 0; + size += msg.reason ? 2 : 0; return size; } #ifdef USE_AREAS -uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->area_id); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); +uint8_t *AreaInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.area_id); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); return pos; } -uint32_t AreaInfo::calculate_size() const { +uint32_t AreaInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->area_id); - size += 2 + this->name.size(); + size += ProtoSize::calc_uint32(1, msg.area_id); + size += 2 + msg.name.size(); return size; } #endif #ifdef USE_DEVICES -uint8_t *DeviceInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->area_id); +uint8_t *DeviceInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.area_id); return pos; } -uint32_t DeviceInfo::calculate_size() const { +uint32_t DeviceInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->device_id); - size += 2 + this->name.size(); - size += ProtoSize::calc_uint32(1, this->area_id); + size += ProtoSize::calc_uint32(1, msg.device_id); + size += 2 + msg.name.size(); + size += ProtoSize::calc_uint32(1, msg.area_id); return size; } #endif #ifdef USE_SERIAL_PROXY -uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states); +uint8_t *SerialProxyInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.name); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.port_type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.configured_line_states); return pos; } -uint32_t SerialProxyInfo::calculate_size() const { +uint32_t SerialProxyInfo::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->name.size()); - size += this->port_type ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->configured_line_states); + size += ProtoSize::calc_length(1, msg.name.size()); + size += msg.port_type ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.configured_line_states); return size; } #endif -uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->name); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->mac_address); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, this->esphome_version); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, this->compilation_time); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, this->model); +uint8_t *DeviceInfoResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.mac_address); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 34, msg.esphome_version); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 42, msg.compilation_time); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 50, msg.model); #ifdef USE_DEEP_SLEEP - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->has_deep_sleep); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, this->project_name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 66, msg.project_name); #endif #ifdef ESPHOME_PROJECT_NAME - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, this->project_version); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 74, msg.project_version); #endif #ifdef USE_WEBSERVER - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->webserver_port); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, this->bluetooth_proxy_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 15, msg.bluetooth_proxy_feature_flags); #endif - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, this->manufacturer); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, this->friendly_name); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 98, msg.manufacturer); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 106, msg.friendly_name); #ifdef USE_VOICE_ASSISTANT - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, this->voice_assistant_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.voice_assistant_feature_flags); #endif #ifdef USE_AREAS - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 16, this->suggested_area, true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.suggested_area); #endif #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 18, this->bluetooth_mac_address, true); + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.bluetooth_mac_address); #endif #ifdef USE_API_NOISE - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, this->api_encryption_supported); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 19, msg.api_encryption_supported); #endif #ifdef USE_DEVICES - for (const auto &it : this->devices) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 20, it); + for (const auto &it : msg.devices) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 20, it); } #endif #ifdef USE_AREAS - for (const auto &it : this->areas) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 21, it); + for (const auto &it : msg.areas) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 21, it); } #endif #ifdef USE_AREAS - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 22, this->area); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 22, msg.area); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, this->zwave_proxy_feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 23, msg.zwave_proxy_feature_flags); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, this->zwave_home_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 24, msg.zwave_home_id); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it); + for (const auto &it : msg.serial_proxies) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 25, it); } #endif #ifdef USE_API_NOISE - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, msg.api_outgoing_connection_supported); #endif return pos; } -uint32_t DeviceInfoResponse::calculate_size() const { +uint32_t DeviceInfoResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->name.size(); - size += 2 + this->mac_address.size(); - size += 2 + this->esphome_version.size(); - size += 2 + this->compilation_time.size(); - size += 2 + this->model.size(); + size += 2 + msg.name.size(); + size += 2 + msg.mac_address.size(); + size += 2 + msg.esphome_version.size(); + size += 2 + msg.compilation_time.size(); + size += 2 + msg.model.size(); #ifdef USE_DEEP_SLEEP - size += ProtoSize::calc_bool(1, this->has_deep_sleep); + size += ProtoSize::calc_bool(1, msg.has_deep_sleep); #endif #ifdef ESPHOME_PROJECT_NAME - size += 2 + this->project_name.size(); + size += 2 + msg.project_name.size(); #endif #ifdef ESPHOME_PROJECT_NAME - size += 2 + this->project_version.size(); + size += 2 + msg.project_version.size(); #endif #ifdef USE_WEBSERVER - size += ProtoSize::calc_uint32(1, this->webserver_port); + size += ProtoSize::calc_uint32(1, msg.webserver_port); #endif #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_uint32(1, this->bluetooth_proxy_feature_flags); + size += ProtoSize::calc_uint32(1, msg.bluetooth_proxy_feature_flags); #endif - size += 2 + this->manufacturer.size(); - size += 2 + this->friendly_name.size(); + size += 2 + msg.manufacturer.size(); + size += 2 + msg.friendly_name.size(); #ifdef USE_VOICE_ASSISTANT - size += ProtoSize::calc_uint32(2, this->voice_assistant_feature_flags); + size += ProtoSize::calc_uint32(2, msg.voice_assistant_feature_flags); #endif #ifdef USE_AREAS - size += 3 + this->suggested_area.size(); + size += 3 + msg.suggested_area.size(); #endif #ifdef USE_BLUETOOTH_PROXY - size += 3 + this->bluetooth_mac_address.size(); + size += 3 + msg.bluetooth_mac_address.size(); #endif #ifdef USE_API_NOISE - size += ProtoSize::calc_bool(2, this->api_encryption_supported); + size += ProtoSize::calc_bool(2, msg.api_encryption_supported); #endif #ifdef USE_DEVICES - for (const auto &it : this->devices) { + for (const auto &it : msg.devices) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_AREAS - for (const auto &it : this->areas) { + for (const auto &it : msg.areas) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_AREAS - size += ProtoSize::calc_message(2, this->area.calculate_size()); + size += ProtoSize::calc_message(2, msg.area.calculate_size()); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_uint32(2, this->zwave_proxy_feature_flags); + size += ProtoSize::calc_uint32(2, msg.zwave_proxy_feature_flags); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_uint32(2, this->zwave_home_id); + size += ProtoSize::calc_uint32(2, msg.zwave_home_id); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { + for (const auto &it : msg.serial_proxies) { size += ProtoSize::calc_message_force(2, it.calculate_size()); } #endif #ifdef USE_API_NOISE - size += ProtoSize::calc_bool(2, this->api_encryption_provisionable); + size += ProtoSize::calc_bool(2, msg.api_encryption_provisionable); +#endif +#ifdef USE_API_OUTGOING_CONNECTION + size += ProtoSize::calc_bool(2, msg.api_outgoing_connection_supported); #endif return size; } #ifdef USE_BLUETOOTH_PROXY -uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); +uint8_t *BluetoothProxyCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.mac_address); return pos; } -uint32_t BluetoothProxyCapabilities::calculate_size() const { +uint32_t BluetoothProxyCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); - size += 2 + this->mac_address.size(); + size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += 2 + msg.mac_address.size(); return size; } #endif #ifdef USE_VOICE_ASSISTANT -uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); +uint8_t *VoiceAssistantCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); return pos; } -uint32_t VoiceAssistantCapabilities::calculate_size() const { +uint32_t VoiceAssistantCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, msg.feature_flags); return size; } #endif #ifdef USE_ZWAVE_PROXY -uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); +uint8_t *ZWaveProxyCapabilities::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.home_id); return pos; } -uint32_t ZWaveProxyCapabilities::calculate_size() const { +uint32_t ZWaveProxyCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->feature_flags); - size += ProtoSize::calc_uint32(1, this->home_id); + size += ProtoSize::calc_uint32(1, msg.feature_flags); + size += ProtoSize::calc_uint32(1, msg.home_id); return size; } #endif -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 +#ifdef USE_SENDSPIN +uint8_t *SendspinCapabilities::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 SendspinCapabilities::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.feature_flags); + return size; +} +#endif +uint8_t *DeviceCapabilitiesResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); #ifdef USE_BLUETOOTH_PROXY - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.bluetooth_proxy); #endif #ifdef USE_VOICE_ASSISTANT - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.voice_assistant); #endif #ifdef USE_ZWAVE_PROXY - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.zwave_proxy); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + for (const auto &it : msg.serial_proxies) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, it); } +#endif +#ifdef USE_API_WIZARD + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wizard); +#endif +#ifdef USE_SENDSPIN + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.sendspin); #endif return pos; } -uint32_t DeviceCapabilitiesResponse::calculate_size() const { +uint32_t DeviceCapabilitiesResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; #ifdef USE_BLUETOOTH_PROXY - size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size()); + size += ProtoSize::calc_message(1, msg.bluetooth_proxy.calculate_size()); #endif #ifdef USE_VOICE_ASSISTANT - size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size()); + size += ProtoSize::calc_message(1, msg.voice_assistant.calculate_size()); #endif #ifdef USE_ZWAVE_PROXY - size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size()); + size += ProtoSize::calc_message(1, msg.zwave_proxy.calculate_size()); #endif #ifdef USE_SERIAL_PROXY - for (const auto &it : this->serial_proxies) { + for (const auto &it : msg.serial_proxies) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } +#endif +#ifdef USE_API_WIZARD + size += ProtoSize::calc_message(1, msg.wizard.calculate_size()); +#endif +#ifdef USE_SENDSPIN + size += ProtoSize::calc_message(1, msg.sendspin.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_progmem_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 auto &it : *msg.effects) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 11, 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_length_force(1, strlen(it)); + size += ProtoSize::calc_float(1, msg.min_mireds); + size += ProtoSize::calc_float(1, msg.max_mireds); + if (!msg.effects->empty()) { + for (const auto &it : *msg.effects) { + size += ProtoSize::calc_length_force(1, it.size()); } } - 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 +1419,1063 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { this->float_array.init(count_float_array); uint32_t count_string_array = ProtoDecodableMessage::count_repeated_field(buffer, length, 9); this->string_array.init(count_string_array); - ProtoDecodableMessage::decode(buffer, length); + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } -bool ExecuteServiceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void ExecuteServiceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.args.emplace_back(); + value.decode_to_message(msg.args.back()); + break; #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 3: - this->call_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.call_id = value.as_varint(); break; #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES - case 4: - this->return_response = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.return_response = value.as_bool(); break; #endif - default: - return false; } - return true; -} -bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: - this->args.emplace_back(); - value.decode_to_message(this->args.back()); - break; - default: - return false; - } - return true; -} -bool ExecuteServiceRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { uint32_t count_args = ProtoDecodableMessage::count_repeated_field(buffer, length, 2); this->args.init(count_args); - ProtoDecodableMessage::decode(buffer, length); + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); } #endif #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES -uint8_t *ExecuteServiceResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->call_id); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error_message); +uint8_t *ExecuteServiceResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.call_id); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error_message); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, this->response_data, this->response_data_len); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.response_data, msg.response_data_len); #endif return pos; } -uint32_t ExecuteServiceResponse::calculate_size() const { +uint32_t ExecuteServiceResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->call_id); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_length(1, this->error_message.size()); + size += ProtoSize::calc_uint32(1, msg.call_id); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_length(1, msg.error_message.size()); #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON - size += ProtoSize::calc_length(1, this->response_data_len); + size += ProtoSize::calc_length(1, msg.response_data_len); #endif return size; } #endif #ifdef USE_CAMERA -uint8_t *ListEntitiesCameraResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); +uint8_t *ListEntitiesCameraResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesCameraResponse::calculate_size() const { +uint32_t ListEntitiesCameraResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->disabled_by_default); + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += this->entity_category ? 2 : 0; + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *CameraImageResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->done); +uint8_t *CameraImageResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data_ptr_, msg.data_len_); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.done); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t CameraImageResponse::calculate_size() const { +uint32_t CameraImageResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->data_len_); - size += ProtoSize::calc_bool(1, this->done); + size += ProtoSize::calc_length(1, msg.data_len_); + size += ProtoSize::calc_bool(1, msg.done); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool CameraImageRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->single = value != 0; +void CameraImageRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.single = value.as_bool(); break; - case 2: - this->stream = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.stream = value.as_bool(); break; - default: - return false; } - return true; } #endif #ifdef USE_CLIMATE -uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->supports_current_temperature); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->supports_two_point_target_temperature); - for (const auto &it : *this->supported_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(it), true); +uint8_t *ListEntitiesClimateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.supports_current_temperature); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.supports_two_point_target_temperature); + for (const auto &it : *msg.supported_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(it)); } - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->visual_min_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->visual_max_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->visual_target_temperature_step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supports_action); - for (const auto &it : *this->supported_fan_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(it), true); + if (uint32_t raw = float_to_raw(msg.visual_min_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - for (const auto &it : *this->supported_swing_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, static_cast(it), true); + if (uint32_t raw = float_to_raw(msg.visual_max_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); } - for (const char *it : *this->supported_custom_fan_modes) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 15, it, strlen(it), true); + if (uint32_t raw = float_to_raw(msg.visual_target_temperature_step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); } - for (const auto &it : *this->supported_presets) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, static_cast(it), true); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supports_action); + for (const auto &it : *msg.supported_fan_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(it)); } - for (const char *it : *this->supported_custom_presets) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 17, it, strlen(it), true); + for (const auto &it : *msg.supported_swing_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 14, static_cast(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, this->disabled_by_default); + for (const char *it : *msg.supported_custom_fan_modes) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 15, it, strlen(it)); + } + for (const auto &it : *msg.supported_presets) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 16, static_cast(it)); + } + for (const char *it : *msg.supported_custom_presets) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 17, it, strlen(it)); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 19, msg.icon); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(this->entity_category)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, this->visual_current_temperature_step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, this->supports_current_humidity); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, this->supports_target_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, this->visual_min_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, this->visual_max_humidity); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 20, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.visual_current_temperature_step); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 22, msg.supports_current_humidity); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 23, msg.supports_target_humidity); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 24, msg.visual_min_humidity); + pos = ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 25, msg.visual_max_humidity); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, msg.feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(msg.temperature_unit)); return pos; } -uint32_t ListEntitiesClimateResponse::calculate_size() const { +uint32_t ListEntitiesClimateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); - size += ProtoSize::calc_bool(1, this->supports_current_temperature); - size += ProtoSize::calc_bool(1, this->supports_two_point_target_temperature); - if (!this->supported_modes->empty()) { - size += this->supported_modes->size() * 2; + size += 2 + msg.name.size(); + size += ProtoSize::calc_bool(1, msg.supports_current_temperature); + size += ProtoSize::calc_bool(1, msg.supports_two_point_target_temperature); + if (!msg.supported_modes->empty()) { + size += msg.supported_modes->size() * 2; } - size += ProtoSize::calc_float(1, this->visual_min_temperature); - size += ProtoSize::calc_float(1, this->visual_max_temperature); - size += ProtoSize::calc_float(1, this->visual_target_temperature_step); - size += ProtoSize::calc_bool(1, this->supports_action); - if (!this->supported_fan_modes->empty()) { - size += this->supported_fan_modes->size() * 2; + size += ProtoSize::calc_float(1, msg.visual_min_temperature); + size += ProtoSize::calc_float(1, msg.visual_max_temperature); + size += ProtoSize::calc_float(1, msg.visual_target_temperature_step); + size += ProtoSize::calc_bool(1, msg.supports_action); + if (!msg.supported_fan_modes->empty()) { + size += msg.supported_fan_modes->size() * 2; } - if (!this->supported_swing_modes->empty()) { - size += this->supported_swing_modes->size() * 2; + if (!msg.supported_swing_modes->empty()) { + size += msg.supported_swing_modes->size() * 2; } - if (!this->supported_custom_fan_modes->empty()) { - for (const char *it : *this->supported_custom_fan_modes) { + if (!msg.supported_custom_fan_modes->empty()) { + for (const char *it : *msg.supported_custom_fan_modes) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - if (!this->supported_presets->empty()) { - size += this->supported_presets->size() * 3; + if (!msg.supported_presets->empty()) { + size += msg.supported_presets->size() * 3; } - if (!this->supported_custom_presets->empty()) { - for (const char *it : *this->supported_custom_presets) { + if (!msg.supported_custom_presets->empty()) { + for (const char *it : *msg.supported_custom_presets) { size += ProtoSize::calc_length_force(2, strlen(it)); } } - size += ProtoSize::calc_bool(2, this->disabled_by_default); + size += ProtoSize::calc_bool(2, msg.disabled_by_default); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 3 + this->icon.size() : 0; + size += !msg.icon.empty() ? 3 + msg.icon.size() : 0; #endif - size += this->entity_category ? 3 : 0; - size += ProtoSize::calc_float(2, this->visual_current_temperature_step); - size += ProtoSize::calc_bool(2, this->supports_current_humidity); - size += ProtoSize::calc_bool(2, this->supports_target_humidity); - size += ProtoSize::calc_float(2, this->visual_min_humidity); - size += ProtoSize::calc_float(2, this->visual_max_humidity); + size += msg.entity_category ? 3 : 0; + size += ProtoSize::calc_float(2, msg.visual_current_temperature_step); + size += ProtoSize::calc_bool(2, msg.supports_current_humidity); + size += ProtoSize::calc_bool(2, msg.supports_target_humidity); + size += ProtoSize::calc_float(2, msg.visual_min_humidity); + size += ProtoSize::calc_float(2, msg.visual_max_humidity); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(2, this->device_id); + size += ProtoSize::calc_uint32(2, msg.device_id); #endif - size += ProtoSize::calc_uint32(2, this->feature_flags); - size += this->temperature_unit ? 3 : 0; + size += ProtoSize::calc_uint32(2, msg.feature_flags); + size += msg.temperature_unit ? 3 : 0; return size; } -uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->current_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 4, this->target_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->target_temperature_low); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->target_temperature_high); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->action)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(this->fan_mode)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->swing_mode)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->custom_fan_mode); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->preset)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->custom_preset); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 14, this->current_humidity); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 15, this->target_humidity); +uint8_t *ClimateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.mode)); + if (uint32_t raw = float_to_raw(msg.current_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 37, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature_low); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature_high); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.action)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, static_cast(msg.fan_mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.swing_mode)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.custom_fan_mode); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(msg.preset)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.custom_preset); + if (uint32_t raw = float_to_raw(msg.current_humidity); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 117, raw); + } + if (uint32_t raw = float_to_raw(msg.target_humidity); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 125, raw); + } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 16, msg.device_id); #endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 17, msg.missing_state); return pos; } -uint32_t ClimateStateResponse::calculate_size() const { +uint32_t ClimateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->mode ? 2 : 0; - size += ProtoSize::calc_float(1, this->current_temperature); - size += ProtoSize::calc_float(1, this->target_temperature); - size += ProtoSize::calc_float(1, this->target_temperature_low); - size += ProtoSize::calc_float(1, this->target_temperature_high); - size += this->action ? 2 : 0; - size += this->fan_mode ? 2 : 0; - size += this->swing_mode ? 2 : 0; - size += ProtoSize::calc_length(1, this->custom_fan_mode.size()); - size += this->preset ? 2 : 0; - size += ProtoSize::calc_length(1, this->custom_preset.size()); - size += ProtoSize::calc_float(1, this->current_humidity); - size += ProtoSize::calc_float(1, this->target_humidity); + size += msg.mode ? 2 : 0; + size += ProtoSize::calc_float(1, msg.current_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature_low); + size += ProtoSize::calc_float(1, msg.target_temperature_high); + size += msg.action ? 2 : 0; + size += msg.fan_mode ? 2 : 0; + size += msg.swing_mode ? 2 : 0; + size += ProtoSize::calc_length(1, msg.custom_fan_mode.size()); + size += msg.preset ? 2 : 0; + size += ProtoSize::calc_length(1, msg.custom_preset.size()); + size += ProtoSize::calc_float(1, msg.current_humidity); + size += ProtoSize::calc_float(1, msg.target_humidity); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(2, this->device_id); + size += ProtoSize::calc_uint32(2, msg.device_id); #endif + size += ProtoSize::calc_bool(2, msg.missing_state); return size; } -bool ClimateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_mode = value != 0; +void ClimateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->mode = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_mode = value.as_bool(); break; - case 4: - this->has_target_temperature = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); break; - case 6: - this->has_target_temperature_low = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.has_target_temperature = value.as_bool(); break; - case 8: - this->has_target_temperature_high = value != 0; + case proto_tag(5, WIRE_TYPE_FIXED32): + msg.target_temperature = value.as_float(); break; - case 12: - this->has_fan_mode = value != 0; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_target_temperature_low = value.as_bool(); break; - case 13: - this->fan_mode = static_cast(value); + case proto_tag(7, WIRE_TYPE_FIXED32): + msg.target_temperature_low = value.as_float(); break; - case 14: - this->has_swing_mode = value != 0; + case proto_tag(8, WIRE_TYPE_VARINT): + msg.has_target_temperature_high = value.as_bool(); break; - case 15: - this->swing_mode = static_cast(value); + case proto_tag(9, WIRE_TYPE_FIXED32): + msg.target_temperature_high = value.as_float(); break; - case 16: - this->has_custom_fan_mode = value != 0; + case proto_tag(12, WIRE_TYPE_VARINT): + msg.has_fan_mode = value.as_bool(); break; - case 18: - this->has_preset = value != 0; + case proto_tag(13, WIRE_TYPE_VARINT): + msg.fan_mode = static_cast(value.as_varint()); break; - case 19: - this->preset = static_cast(value); + case proto_tag(14, WIRE_TYPE_VARINT): + msg.has_swing_mode = value.as_bool(); break; - case 20: - this->has_custom_preset = value != 0; + case proto_tag(15, WIRE_TYPE_VARINT): + msg.swing_mode = static_cast(value.as_varint()); break; - case 22: - this->has_target_humidity = value != 0; + case proto_tag(16, WIRE_TYPE_VARINT): + msg.has_custom_fan_mode = value.as_bool(); + break; + case proto_tag(17, WIRE_TYPE_LENGTH_DELIMITED): + msg.custom_fan_mode = StringRef(value.data(), value.size()); + break; + case proto_tag(18, WIRE_TYPE_VARINT): + msg.has_preset = value.as_bool(); + break; + case proto_tag(19, WIRE_TYPE_VARINT): + msg.preset = static_cast(value.as_varint()); + break; + case proto_tag(20, WIRE_TYPE_VARINT): + msg.has_custom_preset = value.as_bool(); + break; + case proto_tag(21, WIRE_TYPE_LENGTH_DELIMITED): + msg.custom_preset = StringRef(value.data(), value.size()); + break; + case proto_tag(22, WIRE_TYPE_VARINT): + msg.has_target_humidity = value.as_bool(); + break; + case proto_tag(23, WIRE_TYPE_FIXED32): + msg.target_humidity = value.as_float(); break; #ifdef USE_DEVICES - case 24: - this->device_id = value; + case proto_tag(24, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool ClimateCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 17: { - this->custom_fan_mode = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 21: { - this->custom_preset = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool ClimateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 9: - this->target_temperature_high = value.as_float(); - break; - case 23: - this->target_humidity = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_WATER_HEATER -uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesWaterHeaterResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); #endif - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 10, this->target_temperature_step); - for (const auto &it : *this->supported_modes) { - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it), true); + if (uint32_t raw = float_to_raw(msg.min_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit)); + if (uint32_t raw = float_to_raw(msg.max_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 77, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature_step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 85, raw); + } + for (const auto &it : *msg.supported_modes) { + pos = ProtoEncode::encode_uint32_force(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it)); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.supported_features); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(msg.temperature_unit)); return pos; } -uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const { +uint32_t ListEntitiesWaterHeaterResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_float(1, this->min_temperature); - size += ProtoSize::calc_float(1, this->max_temperature); - size += ProtoSize::calc_float(1, this->target_temperature_step); - if (!this->supported_modes->empty()) { - size += this->supported_modes->size() * 2; + size += ProtoSize::calc_float(1, msg.min_temperature); + size += ProtoSize::calc_float(1, msg.max_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature_step); + if (!msg.supported_modes->empty()) { + size += msg.supported_modes->size() * 2; } - size += ProtoSize::calc_uint32(1, this->supported_features); - size += this->temperature_unit ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.supported_features); + size += msg.temperature_unit ? 2 : 0; return size; } -uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->current_temperature); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->target_temperature); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->mode)); +uint8_t *WaterHeaterStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.current_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.mode)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->state); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->target_temperature_low); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->target_temperature_high); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.state); + if (uint32_t raw = float_to_raw(msg.target_temperature_low); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); + } + if (uint32_t raw = float_to_raw(msg.target_temperature_high); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.missing_state); return pos; } -uint32_t WaterHeaterStateResponse::calculate_size() const { +uint32_t WaterHeaterStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->current_temperature); - size += ProtoSize::calc_float(1, this->target_temperature); - size += this->mode ? 2 : 0; + size += ProtoSize::calc_float(1, msg.current_temperature); + size += ProtoSize::calc_float(1, msg.target_temperature); + size += msg.mode ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->state); - size += ProtoSize::calc_float(1, this->target_temperature_low); - size += ProtoSize::calc_float(1, this->target_temperature_high); + size += ProtoSize::calc_uint32(1, msg.state); + size += ProtoSize::calc_float(1, msg.target_temperature_low); + size += ProtoSize::calc_float(1, msg.target_temperature_high); + size += ProtoSize::calc_bool(1, msg.missing_state); return size; } -bool WaterHeaterCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_fields = value; +void WaterHeaterCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->mode = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_fields = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); + break; + case proto_tag(4, WIRE_TYPE_FIXED32): + msg.target_temperature = value.as_float(); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - case 6: - this->state = value; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.state = value.as_varint(); + break; + case proto_tag(7, WIRE_TYPE_FIXED32): + msg.target_temperature_low = value.as_float(); + break; + case proto_tag(8, WIRE_TYPE_FIXED32): + msg.target_temperature_high = value.as_float(); break; - default: - return false; } - return true; -} -bool WaterHeaterCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 4: - this->target_temperature = value.as_float(); - break; - case 7: - this->target_temperature_low = value.as_float(); - break; - case 8: - this->target_temperature_high = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_NUMBER -uint8_t *ListEntitiesNumberResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesNumberResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 6, this->min_value); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 7, this->max_value); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 8, this->step); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->unit_of_measurement); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(this->mode)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, this->device_class); + if (uint32_t raw = float_to_raw(msg.min_value); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 53, raw); + } + if (uint32_t raw = float_to_raw(msg.max_value); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 61, raw); + } + if (uint32_t raw = float_to_raw(msg.step); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 69, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.unit_of_measurement); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, static_cast(msg.mode)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 14, msg.device_id); #endif return pos; } -uint32_t ListEntitiesNumberResponse::calculate_size() const { +uint32_t ListEntitiesNumberResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_float(1, this->min_value); - size += ProtoSize::calc_float(1, this->max_value); - size += ProtoSize::calc_float(1, this->step); - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->unit_of_measurement.empty() ? 2 + this->unit_of_measurement.size() : 0; - size += this->mode ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_float(1, msg.min_value); + size += ProtoSize::calc_float(1, msg.max_value); + size += ProtoSize::calc_float(1, msg.step); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.unit_of_measurement.empty() ? 2 + msg.unit_of_measurement.size() : 0; + size += msg.mode ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *NumberStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); +uint8_t *NumberStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.state); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t NumberStateResponse::calculate_size() const { +uint32_t NumberStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->state); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_float(1, msg.state); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool NumberCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void NumberCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_FIXED32): + msg.state = value.as_float(); + break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool NumberCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->state = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_SELECT -uint8_t *ListEntitiesSelectResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesSelectResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - for (const char *it : *this->options) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it), true); + for (const char *it : *msg.options) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 6, it, strlen(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSelectResponse::calculate_size() const { +uint32_t ListEntitiesSelectResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - if (!this->options->empty()) { - for (const char *it : *this->options) { + if (!msg.options->empty()) { + for (const char *it : *msg.options) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *SelectStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); +uint8_t *SelectStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t SelectStateResponse::calculate_size() const { +uint32_t SelectStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->state.size()); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_length(1, msg.state.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool SelectCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void SelectCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.state = StringRef(value.data(), value.size()); + break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool SelectCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_SIREN -uint8_t *ListEntitiesSirenResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesSirenResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - for (const char *it : *this->tones) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, it, strlen(it), true); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + for (const char *it : *msg.tones) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 7, it, strlen(it)); } - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_duration); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_volume); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supports_duration); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.supports_volume); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t ListEntitiesSirenResponse::calculate_size() const { +uint32_t ListEntitiesSirenResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - if (!this->tones->empty()) { - for (const char *it : *this->tones) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + if (!msg.tones->empty()) { + for (const char *it : *msg.tones) { size += ProtoSize::calc_length_force(1, strlen(it)); } } - size += ProtoSize::calc_bool(1, this->supports_duration); - size += ProtoSize::calc_bool(1, this->supports_volume); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.supports_duration); + size += ProtoSize::calc_bool(1, msg.supports_volume); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *SirenStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); +uint8_t *SirenStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t SirenStateResponse::calculate_size() const { +uint32_t SirenStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->state); + size += ProtoSize::calc_bool(1, msg.state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool SirenCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_state = value != 0; +void SirenCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->state = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_state = value.as_bool(); break; - case 4: - this->has_tone = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.state = value.as_bool(); break; - case 6: - this->has_duration = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.has_tone = value.as_bool(); break; - case 7: - this->duration = value; + case proto_tag(5, WIRE_TYPE_LENGTH_DELIMITED): + msg.tone = StringRef(value.data(), value.size()); break; - case 8: - this->has_volume = value != 0; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_duration = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_VARINT): + msg.duration = value.as_varint(); + break; + case proto_tag(8, WIRE_TYPE_VARINT): + msg.has_volume = value.as_bool(); + break; + case proto_tag(9, WIRE_TYPE_FIXED32): + msg.volume = value.as_float(); break; #ifdef USE_DEVICES - case 10: - this->device_id = value; + case proto_tag(10, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool SirenCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 5: { - this->tone = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool SirenCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 9: - this->volume = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_LOCK -uint8_t *ListEntitiesLockResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesLockResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->supports_open); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, this->code_format); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.supports_open); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.requires_code); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.code_format); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesLockResponse::calculate_size() const { +uint32_t ListEntitiesLockResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->supports_open); - size += ProtoSize::calc_bool(1, this->requires_code); - size += ProtoSize::calc_length(1, this->code_format.size()); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.supports_open); + size += ProtoSize::calc_bool(1, msg.requires_code); + size += ProtoSize::calc_length(1, msg.code_format.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *LockStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); +uint8_t *LockStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t LockStateResponse::calculate_size() const { +uint32_t LockStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; + size += msg.state ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool LockCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); +void LockCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->has_code = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.command = static_cast(value.as_varint()); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.has_code = value.as_bool(); + break; + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + msg.code = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool LockCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 4: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool LockCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_BUTTON -uint8_t *ListEntitiesButtonResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesButtonResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesButtonResponse::calculate_size() const { +uint32_t ListEntitiesButtonResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool ButtonCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void ButtonCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; #ifdef USE_DEVICES - case 2: - this->device_id = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool ButtonCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_MEDIA_PLAYER -uint8_t *MediaPlayerSupportedFormat::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->format); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->sample_rate); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->num_channels); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(this->purpose)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->sample_bytes); +uint8_t *MediaPlayerSupportedFormat::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.format); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.sample_rate); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.num_channels); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast(msg.purpose)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.sample_bytes); return pos; } -uint32_t MediaPlayerSupportedFormat::calculate_size() const { +uint32_t MediaPlayerSupportedFormat::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->format.size()); - size += ProtoSize::calc_uint32(1, this->sample_rate); - size += ProtoSize::calc_uint32(1, this->num_channels); - size += this->purpose ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->sample_bytes); + size += ProtoSize::calc_length(1, msg.format.size()); + size += ProtoSize::calc_uint32(1, msg.sample_rate); + size += ProtoSize::calc_uint32(1, msg.num_channels); + size += msg.purpose ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.sample_bytes); return size; } -uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesMediaPlayerResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - for (auto &it : this->supported_formats) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + for (auto &it : msg.supported_formats) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 9, it); } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->feature_flags); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.feature_flags); return pos; } -uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { +uint32_t ListEntitiesMediaPlayerResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - if (!this->supported_formats.empty()) { - for (const auto &it : this->supported_formats) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + if (!msg.supported_formats.empty()) { + for (const auto &it : msg.supported_formats) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, msg.feature_flags); return size; } -uint8_t *MediaPlayerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->volume); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->muted); +uint8_t *MediaPlayerStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); + if (uint32_t raw = float_to_raw(msg.volume); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.muted); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.device_id); #endif return pos; } -uint32_t MediaPlayerStateResponse::calculate_size() const { +uint32_t MediaPlayerStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; - size += ProtoSize::calc_float(1, this->volume); - size += ProtoSize::calc_bool(1, this->muted); + size += msg.state ? 2 : 0; + size += ProtoSize::calc_float(1, msg.volume); + size += ProtoSize::calc_bool(1, msg.muted); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool MediaPlayerCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_command = value != 0; +void MediaPlayerCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->command = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_command = value.as_bool(); break; - case 4: - this->has_volume = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.command = static_cast(value.as_varint()); break; - case 6: - this->has_media_url = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.has_volume = value.as_bool(); break; - case 8: - this->has_announcement = value != 0; + case proto_tag(5, WIRE_TYPE_FIXED32): + msg.volume = value.as_float(); break; - case 9: - this->announcement = value != 0; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.has_media_url = value.as_bool(); + break; + case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): + msg.media_url = StringRef(value.data(), value.size()); + break; + case proto_tag(8, WIRE_TYPE_VARINT): + msg.has_announcement = value.as_bool(); + break; + case proto_tag(9, WIRE_TYPE_VARINT): + msg.announcement = value.as_bool(); break; #ifdef USE_DEVICES - case 10: - this->device_id = value; + case proto_tag(10, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool MediaPlayerCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 7: { - this->media_url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool MediaPlayerCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 5: - this->volume = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_BLUETOOTH_PROXY -bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->flags = value; +void SubscribeBluetoothLEAdvertisementsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.flags = value.as_varint(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - for (uint16_t i = 0; i < this->advertisements_len; i++) { - auto &sub_msg = this->advertisements[i]; - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); +BluetoothLERawAdvertisementsResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + for (uint16_t i = 0; i < msg.advertisements_len; i++) { + auto &sub_msg = msg.advertisements[i]; + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 10); uint8_t *len_pos = pos; - ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); - ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); - ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); + pos = ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8); + pos = ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16); + pos = ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi)); if (sub_msg.address_type) { - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); - ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 24); + pos = ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address_type); } - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); - ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); - ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 34); + pos = ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, static_cast(sub_msg.data_len)); + pos = ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.data, sub_msg.data_len); *len_pos = static_cast(pos - len_pos - 1); } return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -BluetoothLERawAdvertisementsResponse::calculate_size() const { +BluetoothLERawAdvertisementsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - for (uint16_t i = 0; i < this->advertisements_len; i++) { - auto &sub_msg = this->advertisements[i]; + for (uint16_t i = 0; i < msg.advertisements_len; i++) { + auto &sub_msg = msg.advertisements[i]; size += 2; size += ProtoSize::calc_uint64_48bit_force(1, sub_msg.address); size += ProtoSize::calc_sint32_force(1, sub_msg.rssi); @@ -2499,1809 +2486,1912 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { } #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothDeviceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->request_type = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.request_type = static_cast(value.as_varint()); break; - case 3: - this->has_address_type = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.has_address_type = value.as_bool(); break; - case 4: - this->address_type = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.address_type = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *BluetoothDeviceConnectionResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->connected); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->mtu); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error); +uint8_t *BluetoothDeviceConnectionResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.connected); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.mtu); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.error); return pos; } -uint32_t BluetoothDeviceConnectionResponse::calculate_size() const { +uint32_t BluetoothDeviceConnectionResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->connected); - size += ProtoSize::calc_uint32(1, this->mtu); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.connected); + size += ProtoSize::calc_uint32(1, msg.mtu); + size += ProtoSize::calc_int32(1, msg.error); return size; } -bool BluetoothGATTGetServicesRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTGetServicesRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *BluetoothGATTDescriptor::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); +uint8_t *BluetoothGATTDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.short_uuid); return pos; } -uint32_t BluetoothGATTDescriptor::calculate_size() const { +uint32_t BluetoothGATTDescriptor::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTCharacteristic::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); +uint8_t *BluetoothGATTCharacteristic::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->properties); - for (auto &it : this->descriptors) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.properties); + for (auto &it : msg.descriptors) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.short_uuid); return pos; } -uint32_t BluetoothGATTCharacteristic::calculate_size() const { +uint32_t BluetoothGATTCharacteristic::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_uint32(1, this->properties); - if (!this->descriptors.empty()) { - for (const auto &it : this->descriptors) { + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_uint32(1, msg.properties); + if (!msg.descriptors.empty()) { + for (const auto &it : msg.descriptors) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTService::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[0], true); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->uuid[1], true); +uint8_t *BluetoothGATTService::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[0]); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.uuid[1]); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - for (auto &it : this->characteristics) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, it); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + for (auto &it : msg.characteristics) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->short_uuid); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.short_uuid); return pos; } -uint32_t BluetoothGATTService::calculate_size() const { +uint32_t BluetoothGATTService::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (this->uuid[0] != 0 || this->uuid[1] != 0) { - size += ProtoSize::calc_uint64_force(1, this->uuid[0]); - size += ProtoSize::calc_uint64_force(1, this->uuid[1]); + if (msg.uuid[0] != 0 || msg.uuid[1] != 0) { + size += ProtoSize::calc_uint64_force(1, msg.uuid[0]); + size += ProtoSize::calc_uint64_force(1, msg.uuid[1]); } - size += ProtoSize::calc_uint32(1, this->handle); - if (!this->characteristics.empty()) { - for (const auto &it : this->characteristics) { + size += ProtoSize::calc_uint32(1, msg.handle); + if (!msg.characteristics.empty()) { + for (const auto &it : msg.characteristics) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - size += ProtoSize::calc_uint32(1, this->short_uuid); + size += ProtoSize::calc_uint32(1, msg.short_uuid); return size; } -uint8_t *BluetoothGATTGetServicesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - for (auto &it : this->services) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, it); +uint8_t *BluetoothGATTGetServicesResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + for (auto &it : msg.services) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } return pos; } -uint32_t BluetoothGATTGetServicesResponse::calculate_size() const { +uint32_t BluetoothGATTGetServicesResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - if (!this->services.empty()) { - for (const auto &it : this->services) { + size += ProtoSize::calc_uint64(1, msg.address); + if (!msg.services.empty()) { + for (const auto &it : msg.services) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } return size; } -uint8_t *BluetoothGATTGetServicesDoneResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); +uint8_t *BluetoothGATTGetServicesDoneResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); return pos; } -uint32_t BluetoothGATTGetServicesDoneResponse::calculate_size() const { +uint32_t BluetoothGATTGetServicesDoneResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); + size += ProtoSize::calc_uint64(1, msg.address); return size; } -bool BluetoothGATTReadRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTReadRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *BluetoothGATTReadResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); +uint8_t *BluetoothGATTReadResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data_ptr_, msg.data_len_); return pos; } -uint32_t BluetoothGATTReadResponse::calculate_size() const { +uint32_t BluetoothGATTReadResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } -bool BluetoothGATTWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTWriteRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); break; - case 3: - this->response = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.response = value.as_bool(); + break; + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - default: - return false; } - return true; } -bool BluetoothGATTWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 4: { - this->data = value.data(); - this->data_len = value.size(); +void BluetoothGATTReadDescriptorRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); break; - } - default: - return false; } - return true; } -bool BluetoothGATTReadDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTWriteDescriptorRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - default: - return false; } - return true; } -bool BluetoothGATTWriteDescriptorRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothGATTNotifyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->handle = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.handle = value.as_varint(); + break; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.enable = value.as_bool(); break; - default: - return false; } - return true; } -bool BluetoothGATTWriteDescriptorRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - default: - return false; - } - return true; -} -bool BluetoothGATTNotifyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; - break; - case 2: - this->handle = value; - break; - case 3: - this->enable = value != 0; - break; - default: - return false; - } - return true; -} -uint8_t *BluetoothGATTNotifyDataResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data_ptr_, this->data_len_); +uint8_t *BluetoothGATTNotifyDataResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data_ptr_, msg.data_len_); return pos; } -uint32_t BluetoothGATTNotifyDataResponse::calculate_size() const { +uint32_t BluetoothGATTNotifyDataResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } -uint8_t *BluetoothConnectionsFreeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->free); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->limit); - for (const auto &it : this->allocated) { +uint8_t *BluetoothConnectionsFreeResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.free); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.limit); + for (const auto &it : msg.allocated) { if (it != 0) { - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); + pos = ProtoEncode::encode_uint64_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } } return pos; } -uint32_t BluetoothConnectionsFreeResponse::calculate_size() const { +uint32_t BluetoothConnectionsFreeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->free); - size += ProtoSize::calc_uint32(1, this->limit); - for (const auto &it : this->allocated) { + size += ProtoSize::calc_uint32(1, msg.free); + size += ProtoSize::calc_uint32(1, msg.limit); + for (const auto &it : msg.allocated) { if (it != 0) { size += ProtoSize::calc_uint64_force(1, it); } } return size; } -uint8_t *BluetoothGATTErrorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); +uint8_t *BluetoothGATTErrorResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothGATTErrorResponse::calculate_size() const { +uint32_t BluetoothGATTErrorResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothGATTWriteResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); +uint8_t *BluetoothGATTWriteResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); return pos; } -uint32_t BluetoothGATTWriteResponse::calculate_size() const { +uint32_t BluetoothGATTWriteResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); return size; } -uint8_t *BluetoothGATTNotifyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->handle); +uint8_t *BluetoothGATTNotifyResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.handle); return pos; } -uint32_t BluetoothGATTNotifyResponse::calculate_size() const { +uint32_t BluetoothGATTNotifyResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_uint32(1, this->handle); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_uint32(1, msg.handle); return size; } -uint8_t *BluetoothDevicePairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->paired); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); +uint8_t *BluetoothDevicePairingResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.paired); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDevicePairingResponse::calculate_size() const { +uint32_t BluetoothDevicePairingResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->paired); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.paired); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothDeviceUnpairingResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); +uint8_t *BluetoothDeviceUnpairingResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDeviceUnpairingResponse::calculate_size() const { +uint32_t BluetoothDeviceUnpairingResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_int32(1, msg.error); return size; } -uint8_t *BluetoothDeviceClearCacheResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->success); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->error); +uint8_t *BluetoothDeviceClearCacheResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.success); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.error); return pos; } -uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { +uint32_t BluetoothDeviceClearCacheResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_bool(1, this->success); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_bool(1, msg.success); + size += ProtoSize::calc_int32(1, msg.error); return size; } #endif #ifdef USE_BLUETOOTH_PROXY -uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->mode)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->configured_mode)); +uint8_t *BluetoothScannerStateResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.state)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.mode)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.configured_mode)); return pos; } -uint32_t BluetoothScannerStateResponse::calculate_size() const { +uint32_t BluetoothScannerStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->state ? 2 : 0; - size += this->mode ? 2 : 0; - size += this->configured_mode ? 2 : 0; + size += msg.state ? 2 : 0; + size += msg.mode ? 2 : 0; + size += msg.configured_mode ? 2 : 0; return size; } -bool BluetoothScannerSetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->mode = static_cast(value); +void BluetoothScannerSetModeRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); break; - default: - return false; } - return true; } #endif #ifdef USE_VOICE_ASSISTANT -bool SubscribeVoiceAssistantRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->subscribe = value != 0; +void SubscribeVoiceAssistantRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.subscribe = value.as_bool(); break; - case 2: - this->flags = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.flags = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *VoiceAssistantAudioSettings::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->noise_suppression_level); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->auto_gain); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 3, this->volume_multiplier); +uint8_t *VoiceAssistantAudioSettings::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.noise_suppression_level); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.auto_gain); + if (uint32_t raw = float_to_raw(msg.volume_multiplier); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } return pos; } -uint32_t VoiceAssistantAudioSettings::calculate_size() const { +uint32_t VoiceAssistantAudioSettings::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->noise_suppression_level); - size += ProtoSize::calc_uint32(1, this->auto_gain); - size += ProtoSize::calc_float(1, this->volume_multiplier); + size += ProtoSize::calc_uint32(1, msg.noise_suppression_level); + size += ProtoSize::calc_uint32(1, msg.auto_gain); + size += ProtoSize::calc_float(1, msg.volume_multiplier); return size; } -uint8_t *VoiceAssistantRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->start); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->conversation_id); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->flags); - ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, this->audio_settings); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->wake_word_phrase); +uint8_t *VoiceAssistantRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.start); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.conversation_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.audio_settings); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.wake_word_phrase); return pos; } -uint32_t VoiceAssistantRequest::calculate_size() const { +uint32_t VoiceAssistantRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->start); - size += ProtoSize::calc_length(1, this->conversation_id.size()); - size += ProtoSize::calc_uint32(1, this->flags); - size += ProtoSize::calc_message(1, this->audio_settings.calculate_size()); - size += ProtoSize::calc_length(1, this->wake_word_phrase.size()); + size += ProtoSize::calc_bool(1, msg.start); + size += ProtoSize::calc_length(1, msg.conversation_id.size()); + size += ProtoSize::calc_uint32(1, msg.flags); + size += ProtoSize::calc_message(1, msg.audio_settings.calculate_size()); + size += ProtoSize::calc_length(1, msg.wake_word_phrase.size()); return size; } -bool VoiceAssistantResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->port = value; +void VoiceAssistantResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.port = value.as_varint(); break; - case 2: - this->error = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.error = value.as_bool(); break; - default: - return false; } - return true; } -bool VoiceAssistantEventData::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); +void VoiceAssistantEventData::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.name = StringRef(value.data(), value.size()); break; - } - case 2: { - this->value = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.value = StringRef(value.data(), value.size()); break; - } - default: - return false; } - return true; } -bool VoiceAssistantEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->event_type = static_cast(value); +void VoiceAssistantEventResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.event_type = static_cast(value.as_varint()); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.data.emplace_back(); + value.decode_to_message(msg.data.back()); break; - default: - return false; } - return true; } -bool VoiceAssistantEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: - this->data.emplace_back(); - value.decode_to_message(this->data.back()); +void VoiceAssistantAudio::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.end = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.data2 = value.data(); + msg.data2_len = value.size(); break; - default: - return false; } - return true; } -bool VoiceAssistantAudio::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->end = value != 0; - break; - default: - return false; - } - return true; -} -bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->data = value.data(); - this->data_len = value.size(); - break; - } - case 3: { - this->data2 = value.data(); - this->data2_len = value.size(); - break; - } - default: - return false; - } - return true; -} -uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len); +uint8_t *VoiceAssistantAudio::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.end); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.data2, msg.data2_len); return pos; } -uint32_t VoiceAssistantAudio::calculate_size() const { +uint32_t VoiceAssistantAudio::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->data_len); - size += ProtoSize::calc_bool(1, this->end); - size += ProtoSize::calc_length(1, this->data2_len); + size += ProtoSize::calc_length(1, msg.data_len); + size += ProtoSize::calc_bool(1, msg.end); + size += ProtoSize::calc_length(1, msg.data2_len); return size; } -bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->event_type = static_cast(value); +void VoiceAssistantTimerEventResponse::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.event_type = static_cast(value.as_varint()); break; - case 4: - this->total_seconds = value; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.timer_id = StringRef(value.data(), value.size()); break; - case 5: - this->seconds_left = value; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.name = StringRef(value.data(), value.size()); break; - case 6: - this->is_active = value != 0; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.total_seconds = value.as_varint(); + break; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.seconds_left = value.as_varint(); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.is_active = value.as_bool(); break; - default: - return false; } - return true; } -bool VoiceAssistantTimerEventResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->timer_id = StringRef(reinterpret_cast(value.data()), value.size()); +void VoiceAssistantAnnounceRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.media_id = StringRef(value.data(), value.size()); break; - } - case 3: { - this->name = StringRef(reinterpret_cast(value.data()), value.size()); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.text = StringRef(value.data(), value.size()); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.preannounce_media_id = StringRef(value.data(), value.size()); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.start_conversation = value.as_bool(); break; - } - default: - return false; } - return true; } -bool VoiceAssistantAnnounceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 4: - this->start_conversation = value != 0; - break; - default: - return false; - } - return true; -} -bool VoiceAssistantAnnounceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 2: { - this->text = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: { - this->preannounce_media_id = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -uint8_t *VoiceAssistantAnnounceFinished::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, this->success); +uint8_t *VoiceAssistantAnnounceFinished::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.success); return pos; } -uint32_t VoiceAssistantAnnounceFinished::calculate_size() const { +uint32_t VoiceAssistantAnnounceFinished::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_bool(1, this->success); + size += ProtoSize::calc_bool(1, msg.success); return size; } -uint8_t *VoiceAssistantWakeWord::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->id); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->wake_word); - for (auto &it : this->trained_languages) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); +uint8_t *VoiceAssistantWakeWord::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.id); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.wake_word); + for (auto &it : msg.trained_languages) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } return pos; } -uint32_t VoiceAssistantWakeWord::calculate_size() const { +uint32_t VoiceAssistantWakeWord::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->id.size()); - size += ProtoSize::calc_length(1, this->wake_word.size()); - if (!this->trained_languages.empty()) { - for (const auto &it : this->trained_languages) { + size += ProtoSize::calc_length(1, msg.id.size()); + size += ProtoSize::calc_length(1, msg.wake_word.size()); + if (!msg.trained_languages.empty()) { + for (const auto &it : msg.trained_languages) { size += ProtoSize::calc_length_force(1, it.size()); } } return size; } -bool VoiceAssistantExternalWakeWord::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 5: - this->model_size = value; +void VoiceAssistantExternalWakeWord::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.id = StringRef(value.data(), value.size()); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.wake_word = StringRef(value.data(), value.size()); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.trained_languages.push_back(value.as_string()); + break; + case proto_tag(4, WIRE_TYPE_LENGTH_DELIMITED): + msg.model_type = StringRef(value.data(), value.size()); + break; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.model_size = value.as_varint(); + break; + case proto_tag(6, WIRE_TYPE_LENGTH_DELIMITED): + msg.model_hash = StringRef(value.data(), value.size()); + break; + case proto_tag(7, WIRE_TYPE_LENGTH_DELIMITED): + msg.url = StringRef(value.data(), value.size()); break; - default: - return false; } - return true; } -bool VoiceAssistantExternalWakeWord::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->id = StringRef(reinterpret_cast(value.data()), value.size()); +void VoiceAssistantConfigurationRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.external_wake_words.emplace_back(); + value.decode_to_message(msg.external_wake_words.back()); break; - } - case 2: { - this->wake_word = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 3: - this->trained_languages.push_back(value.as_string()); - break; - case 4: { - this->model_type = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 6: { - this->model_hash = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - case 7: { - this->url = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; } - return true; } -bool VoiceAssistantConfigurationRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: - this->external_wake_words.emplace_back(); - value.decode_to_message(this->external_wake_words.back()); - break; - default: - return false; +uint8_t *VoiceAssistantConfigurationResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + for (auto &it : msg.available_wake_words) { + pos = ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 1, it); } - return true; -} -uint8_t *VoiceAssistantConfigurationResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - for (auto &it : this->available_wake_words) { - ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, it); + for (const auto &it : *msg.active_wake_words) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 2, it); } - for (const auto &it : *this->active_wake_words) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, it, true); - } - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->max_active_wake_words); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.max_active_wake_words); return pos; } -uint32_t VoiceAssistantConfigurationResponse::calculate_size() const { +uint32_t VoiceAssistantConfigurationResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - if (!this->available_wake_words.empty()) { - for (const auto &it : this->available_wake_words) { + if (!msg.available_wake_words.empty()) { + for (const auto &it : msg.available_wake_words) { size += ProtoSize::calc_message_force(1, it.calculate_size()); } } - if (!this->active_wake_words->empty()) { - for (const auto &it : *this->active_wake_words) { + if (!msg.active_wake_words->empty()) { + for (const auto &it : *msg.active_wake_words) { size += ProtoSize::calc_length_force(1, it.size()); } } - size += ProtoSize::calc_uint32(1, this->max_active_wake_words); + size += ProtoSize::calc_uint32(1, msg.max_active_wake_words); return size; } -bool VoiceAssistantSetConfiguration::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: - this->active_wake_words.push_back(value.as_string()); +void VoiceAssistantSetConfiguration::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.active_wake_words.push_back(value.as_string()); break; - default: - return false; } - return true; } #endif #ifdef USE_ALARM_CONTROL_PANEL -uint8_t *ListEntitiesAlarmControlPanelResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesAlarmControlPanelResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supported_features); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->requires_code); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->requires_code_to_arm); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.supported_features); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.requires_code); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.requires_code_to_arm); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t ListEntitiesAlarmControlPanelResponse::calculate_size() const { +uint32_t ListEntitiesAlarmControlPanelResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->supported_features); - size += ProtoSize::calc_bool(1, this->requires_code); - size += ProtoSize::calc_bool(1, this->requires_code_to_arm); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.supported_features); + size += ProtoSize::calc_bool(1, msg.requires_code); + size += ProtoSize::calc_bool(1, msg.requires_code_to_arm); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *AlarmControlPanelStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->state)); +uint8_t *AlarmControlPanelStateResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.state)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t AlarmControlPanelStateResponse::calculate_size() const { +uint32_t AlarmControlPanelStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += this->state ? 2 : 0; + size += msg.state ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool AlarmControlPanelCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); +void AlarmControlPanelCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.command = static_cast(value.as_varint()); + break; + case proto_tag(3, WIRE_TYPE_LENGTH_DELIMITED): + msg.code = StringRef(value.data(), value.size()); break; #ifdef USE_DEVICES - case 4: - this->device_id = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool AlarmControlPanelCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 3: { - this->code = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool AlarmControlPanelCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_TEXT -uint8_t *ListEntitiesTextResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesTextResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->min_length); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->max_length); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->pattern); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(this->mode)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.min_length); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.max_length); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.pattern); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(msg.mode)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesTextResponse::calculate_size() const { +uint32_t ListEntitiesTextResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_uint32(1, this->min_length); - size += ProtoSize::calc_uint32(1, this->max_length); - size += ProtoSize::calc_length(1, this->pattern.size()); - size += this->mode ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.min_length); + size += ProtoSize::calc_uint32(1, msg.max_length); + size += ProtoSize::calc_length(1, msg.pattern.size()); + size += msg.mode ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *TextStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->missing_state); +uint8_t *TextStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.missing_state); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t TextStateResponse::calculate_size() const { +uint32_t TextStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->state.size()); - size += ProtoSize::calc_bool(1, this->missing_state); + size += ProtoSize::calc_length(1, msg.state.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool TextCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void TextCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.state = StringRef(value.data(), value.size()); + break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool TextCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->state = StringRef(reinterpret_cast(value.data()), value.size()); - break; - } - default: - return false; - } - return true; -} -bool TextCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_DATETIME_DATE -uint8_t *ListEntitiesDateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesDateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesDateResponse::calculate_size() const { +uint32_t ListEntitiesDateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *DateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->year); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->month); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->day); +uint8_t *DateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.year); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.month); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.day); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.device_id); #endif return pos; } -uint32_t DateStateResponse::calculate_size() const { +uint32_t DateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_uint32(1, this->year); - size += ProtoSize::calc_uint32(1, this->month); - size += ProtoSize::calc_uint32(1, this->day); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_uint32(1, msg.year); + size += ProtoSize::calc_uint32(1, msg.month); + size += ProtoSize::calc_uint32(1, msg.day); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool DateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->year = value; +void DateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->month = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.year = value.as_varint(); break; - case 4: - this->day = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.month = value.as_varint(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.day = value.as_varint(); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool DateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_DATETIME_TIME -uint8_t *ListEntitiesTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesTimeResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesTimeResponse::calculate_size() const { +uint32_t ListEntitiesTimeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *TimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->hour); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->minute); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, this->second); +uint8_t *TimeStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.hour); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.minute); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.second); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.device_id); #endif return pos; } -uint32_t TimeStateResponse::calculate_size() const { +uint32_t TimeStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_uint32(1, this->hour); - size += ProtoSize::calc_uint32(1, this->minute); - size += ProtoSize::calc_uint32(1, this->second); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_uint32(1, msg.hour); + size += ProtoSize::calc_uint32(1, msg.minute); + size += ProtoSize::calc_uint32(1, msg.second); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool TimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->hour = value; +void TimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 3: - this->minute = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.hour = value.as_varint(); break; - case 4: - this->second = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.minute = value.as_varint(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.second = value.as_varint(); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool TimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_EVENT -uint8_t *ListEntitiesEventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesEventResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - for (const char *it : *this->event_types) { - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it), true); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + for (const char *it : *msg.event_types) { + pos = ProtoEncode::encode_string_force(pos PROTO_ENCODE_DEBUG_ARG, 9, it, strlen(it)); } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.device_id); #endif return pos; } -uint32_t ListEntitiesEventResponse::calculate_size() const { +uint32_t ListEntitiesEventResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - if (!this->event_types->empty()) { - for (const char *it : *this->event_types) { + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + if (!msg.event_types->empty()) { + for (const char *it : *msg.event_types) { size += ProtoSize::calc_length_force(1, strlen(it)); } } #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *EventResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, this->event_type); +uint8_t *EventResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.event_type); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.device_id); #endif return pos; } -uint32_t EventResponse::calculate_size() const { +uint32_t EventResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_length(1, this->event_type.size()); + size += ProtoSize::calc_length(1, msg.event_type.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } #endif #ifdef USE_VALVE -uint8_t *ListEntitiesValveResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesValveResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, this->assumed_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, this->supports_position); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supports_stop); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.assumed_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.supports_position); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.supports_stop); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, msg.device_id); #endif return pos; } -uint32_t ListEntitiesValveResponse::calculate_size() const { +uint32_t ListEntitiesValveResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; - size += ProtoSize::calc_bool(1, this->assumed_state); - size += ProtoSize::calc_bool(1, this->supports_position); - size += ProtoSize::calc_bool(1, this->supports_stop); + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.assumed_state); + size += ProtoSize::calc_bool(1, msg.supports_position); + size += ProtoSize::calc_bool(1, msg.supports_stop); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *ValveStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->position); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->current_operation)); +uint8_t *ValveStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + if (uint32_t raw = float_to_raw(msg.position); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, raw); + } + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.current_operation)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t ValveStateResponse::calculate_size() const { +uint32_t ValveStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_float(1, this->position); - size += this->current_operation ? 2 : 0; + size += ProtoSize::calc_float(1, msg.position); + size += msg.current_operation ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool ValveCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->has_position = value != 0; +void ValveCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 4: - this->stop = value != 0; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.has_position = value.as_bool(); + break; + case proto_tag(3, WIRE_TYPE_FIXED32): + msg.position = value.as_float(); + break; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.stop = value.as_bool(); break; #ifdef USE_DEVICES - case 5: - this->device_id = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool ValveCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 3: - this->position = value.as_float(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_DATETIME_DATETIME -uint8_t *ListEntitiesDateTimeResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesDateTimeResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_id); #endif return pos; } -uint32_t ListEntitiesDateTimeResponse::calculate_size() const { +uint32_t ListEntitiesDateTimeResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *DateTimeStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->epoch_seconds); +uint8_t *DateTimeStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + if (uint32_t raw = msg.epoch_seconds; raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 29, raw); + } #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.device_id); #endif return pos; } -uint32_t DateTimeStateResponse::calculate_size() const { +uint32_t DateTimeStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_fixed32(1, this->epoch_seconds); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_fixed32(1, msg.epoch_seconds); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool DateTimeCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +void DateTimeCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_FIXED32): + msg.epoch_seconds = value.as_fixed32(); + break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool DateTimeCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - case 2: - this->epoch_seconds = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_UPDATE -uint8_t *ListEntitiesUpdateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesUpdateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->device_class); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(msg.entity_category)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.device_class); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.device_id); #endif return pos; } -uint32_t ListEntitiesUpdateResponse::calculate_size() const { +uint32_t ListEntitiesUpdateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; - size += !this->device_class.empty() ? 2 + this->device_class.size() : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; + size += !msg.device_class.empty() ? 2 + msg.device_class.size() : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -uint8_t *UpdateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->missing_state); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->in_progress); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, this->has_progress); - ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 5, this->progress); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, this->current_version); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, this->latest_version); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, this->title); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, this->release_summary); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, this->release_url); +uint8_t *UpdateStateResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.missing_state); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.in_progress); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.has_progress); + if (uint32_t raw = float_to_raw(msg.progress); raw != 0) [[likely]] { + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 45, raw); + } + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.current_version); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.latest_version); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.title); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.release_summary); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.release_url); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.device_id); #endif return pos; } -uint32_t UpdateStateResponse::calculate_size() const { +uint32_t UpdateStateResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; size += 5; - size += ProtoSize::calc_bool(1, this->missing_state); - size += ProtoSize::calc_bool(1, this->in_progress); - size += ProtoSize::calc_bool(1, this->has_progress); - size += ProtoSize::calc_float(1, this->progress); - size += ProtoSize::calc_length(1, this->current_version.size()); - size += ProtoSize::calc_length(1, this->latest_version.size()); - size += ProtoSize::calc_length(1, this->title.size()); - size += ProtoSize::calc_length(1, this->release_summary.size()); - size += ProtoSize::calc_length(1, this->release_url.size()); + size += ProtoSize::calc_bool(1, msg.missing_state); + size += ProtoSize::calc_bool(1, msg.in_progress); + size += ProtoSize::calc_bool(1, msg.has_progress); + size += ProtoSize::calc_float(1, msg.progress); + size += ProtoSize::calc_length(1, msg.current_version.size()); + size += ProtoSize::calc_length(1, msg.latest_version.size()); + size += ProtoSize::calc_length(1, msg.title.size()); + size += ProtoSize::calc_length(1, msg.release_summary.size()); + size += ProtoSize::calc_length(1, msg.release_url.size()); #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif return size; } -bool UpdateCommandRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 2: - this->command = static_cast(value); +void UpdateCommandRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.command = static_cast(value.as_varint()); break; #ifdef USE_DEVICES - case 3: - this->device_id = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - default: - return false; } - return true; -} -bool UpdateCommandRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 1: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } #endif #ifdef USE_ZWAVE_PROXY -bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 1: { - this->data = value.data(); - this->data_len = value.size(); +void ZWaveProxyFrame::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - } - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len); +ZWaveProxyFrame::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.data, msg.data_len); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -ZWaveProxyFrame::calculate_size() const { +ZWaveProxyFrame::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_length(1, this->data_len); + size += ProtoSize::calc_length(1, msg.data_len); return size; } -bool ZWaveProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->type = static_cast(value); +void ZWaveProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.type = static_cast(value.as_varint()); break; - default: - return false; - } - return true; -} -bool ZWaveProxyRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->data = value.data(); - this->data_len = value.size(); + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - } - default: - return false; } - return true; } -uint8_t *ZWaveProxyRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data, this->data_len); +uint8_t *ZWaveProxyRequest::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.type)); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data, msg.data_len); return pos; } -uint32_t ZWaveProxyRequest::calculate_size() const { +uint32_t ZWaveProxyRequest::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->type ? 2 : 0; - size += ProtoSize::calc_length(1, this->data_len); + size += msg.type ? 2 : 0; + size += ProtoSize::calc_length(1, msg.data_len); return size; } -uint8_t *ZWaveProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->status)); +uint8_t *ZWaveProxyRequestResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(msg.type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.status)); return pos; } -uint32_t ZWaveProxyRequestResponse::calculate_size() const { +uint32_t ZWaveProxyRequestResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += this->type ? 2 : 0; - size += this->status ? 2 : 0; + size += msg.type ? 2 : 0; + size += msg.status ? 2 : 0; return size; } #endif #ifdef USE_INFRARED -uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); +uint8_t *ListEntitiesInfraredResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); #ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); #endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->receiver_frequency); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.capabilities); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.receiver_frequency); return pos; } -uint32_t ListEntitiesInfraredResponse::calculate_size() const { +uint32_t ListEntitiesInfraredResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); + size += 2 + msg.object_id.size(); size += 5; - size += 2 + this->name.size(); + size += 2 + msg.name.size(); #ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; #endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->capabilities); - size += ProtoSize::calc_uint32(1, this->receiver_frequency); + size += ProtoSize::calc_uint32(1, msg.capabilities); + size += ProtoSize::calc_uint32(1, msg.receiver_frequency); return size; } #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { +#ifdef USE_IR_RF +void InfraredRFTransmitRawTimingsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { #ifdef USE_DEVICES - case 1: - this->device_id = value; + case proto_tag(1, WIRE_TYPE_VARINT): + msg.device_id = value.as_varint(); break; #endif - case 3: - this->carrier_frequency = value; + case proto_tag(2, WIRE_TYPE_FIXED32): + msg.key = value.as_fixed32(); break; - case 4: - this->repeat_count = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.carrier_frequency = value.as_varint(); break; - case 6: - this->modulation = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.repeat_count = value.as_varint(); + break; + case proto_tag(5, WIRE_TYPE_LENGTH_DELIMITED): + msg.timings_data_ = value.data(); + msg.timings_length_ = value.size(); + msg.timings_count_ = count_packed_varints(value.data(), value.size()); + break; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.modulation = value.as_varint(); break; - default: - return false; } - return true; -} -bool InfraredRFTransmitRawTimingsRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 5: { - this->timings_data_ = value.data(); - this->timings_length_ = value.size(); - this->timings_count_ = count_packed_varints(value.data(), value.size()); - break; - } - default: - return false; - } - return true; -} -bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto32Bit value) { - switch (field_id) { - case 2: - this->key = value.as_fixed32(); - break; - default: - return false; - } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); +InfraredRFReceiveEvent::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); #endif - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - for (const auto &it : *this->timings) { - ProtoEncode::encode_sint32(pos PROTO_ENCODE_DEBUG_ARG, 3, it, true); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + for (const auto &it : *msg.timings) { + pos = ProtoEncode::encode_sint32_force(pos PROTO_ENCODE_DEBUG_ARG, 3, it); } return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -InfraredRFReceiveEvent::calculate_size() const { +InfraredRFReceiveEvent::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif size += 5; - if (!this->timings->empty()) { - for (const auto &it : *this->timings) { + if (!msg.timings->empty()) { + for (const auto &it : *msg.timings) { size += ProtoSize::calc_sint32_force(1, it); } } return size; } -#endif -#ifdef USE_RADIO_FREQUENCY -uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id); - ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key); - ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name); -#ifdef USE_ENTITY_ICON - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon); -#endif - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category)); +uint8_t *InfraredRFTransmitCompleteResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); #ifdef USE_DEVICES - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id); #endif - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.success); return pos; } -uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const { +uint32_t InfraredRFTransmitCompleteResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += 2 + this->object_id.size(); - size += 5; - size += 2 + this->name.size(); -#ifdef USE_ENTITY_ICON - size += !this->icon.empty() ? 2 + this->icon.size() : 0; -#endif - size += ProtoSize::calc_bool(1, this->disabled_by_default); - size += this->entity_category ? 2 : 0; #ifdef USE_DEVICES - size += ProtoSize::calc_uint32(1, this->device_id); + size += ProtoSize::calc_uint32(1, msg.device_id); #endif - size += ProtoSize::calc_uint32(1, this->capabilities); - size += ProtoSize::calc_uint32(1, this->frequency_min); - size += ProtoSize::calc_uint32(1, this->frequency_max); - size += ProtoSize::calc_uint32(1, this->supported_modulations); + size += 5; + size += ProtoSize::calc_bool(1, msg.success); + return size; +} +#endif +#ifdef USE_RADIO_FREQUENCY +uint8_t *ListEntitiesRadioFrequencyResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.object_id); + pos = ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, msg.key); + pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, msg.name); +#ifdef USE_ENTITY_ICON + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.icon); +#endif + pos = ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.disabled_by_default); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(msg.entity_category)); +#ifdef USE_DEVICES + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.device_id); +#endif + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, msg.capabilities); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, msg.frequency_min); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, msg.frequency_max); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, msg.supported_modulations); + return pos; +} +uint32_t ListEntitiesRadioFrequencyResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += 2 + msg.object_id.size(); + size += 5; + size += 2 + msg.name.size(); +#ifdef USE_ENTITY_ICON + size += !msg.icon.empty() ? 2 + msg.icon.size() : 0; +#endif + size += ProtoSize::calc_bool(1, msg.disabled_by_default); + size += msg.entity_category ? 2 : 0; +#ifdef USE_DEVICES + size += ProtoSize::calc_uint32(1, msg.device_id); +#endif + size += ProtoSize::calc_uint32(1, msg.capabilities); + size += ProtoSize::calc_uint32(1, msg.frequency_min); + size += ProtoSize::calc_uint32(1, msg.frequency_max); + size += ProtoSize::calc_uint32(1, msg.supported_modulations); return size; } #endif #ifdef USE_SERIAL_PROXY -bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyConfigureRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); break; - case 2: - this->baudrate = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.baudrate = value.as_varint(); break; - case 3: - this->flow_control = value != 0; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.flow_control = value.as_bool(); break; - case 4: - this->parity = static_cast(value); + case proto_tag(4, WIRE_TYPE_VARINT): + msg.parity = static_cast(value.as_varint()); break; - case 5: - this->stop_bits = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.stop_bits = value.as_varint(); break; - case 6: - this->data_size = value; + case proto_tag(6, WIRE_TYPE_VARINT): + msg.data_size = value.as_varint(); break; - default: - return false; } - return true; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint8_t * -SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_); +SerialProxyDataReceived::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.data_ptr_, msg.data_len_); return pos; } __attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes) uint32_t -SerialProxyDataReceived::calculate_size() const { +SerialProxyDataReceived::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += ProtoSize::calc_length(1, this->data_len_); + size += ProtoSize::calc_uint32(1, msg.instance); + size += ProtoSize::calc_length(1, msg.data_len_); return size; } -bool SerialProxyWriteRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyWriteRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); + break; + case proto_tag(2, WIRE_TYPE_LENGTH_DELIMITED): + msg.data = value.data(); + msg.data_len = value.size(); break; - default: - return false; } - return true; } -bool SerialProxyWriteRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { - switch (field_id) { - case 2: { - this->data = value.data(); - this->data_len = value.size(); +void SerialProxySetModemPinsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); + break; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.line_states = value.as_varint(); break; - } - default: - return false; } - return true; } -bool SerialProxySetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyGetModemPinsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); break; - case 2: - this->line_states = value; - break; - default: - return false; } - return true; } -bool SerialProxyGetModemPinsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; - break; - default: - return false; - } - return true; -} -uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); +uint8_t *SerialProxyGetModemPinsResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.line_states); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.status)); return pos; } -uint32_t SerialProxyGetModemPinsResponse::calculate_size() const { +uint32_t SerialProxyGetModemPinsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += ProtoSize::calc_uint32(1, this->line_states); - size += this->status ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.instance); + size += ProtoSize::calc_uint32(1, msg.line_states); + size += msg.status ? 2 : 0; return size; } -bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxyRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); break; - case 2: - this->type = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.type = static_cast(value.as_varint()); break; - default: - return false; } - return true; } -uint8_t *SerialProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->type)); - ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status)); - ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->error_message); +uint8_t *SerialProxyRequestResponse::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.type)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(msg.status)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.error_message); return pos; } -uint32_t SerialProxyRequestResponse::calculate_size() const { +uint32_t SerialProxyRequestResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint32(1, this->instance); - size += this->type ? 2 : 0; - size += this->status ? 2 : 0; - size += ProtoSize::calc_length(1, this->error_message.size()); + size += ProtoSize::calc_uint32(1, msg.instance); + size += msg.type ? 2 : 0; + size += msg.status ? 2 : 0; + size += ProtoSize::calc_length(1, msg.error_message.size()); return size; } -bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->instance = value; +void SerialProxySetModeRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.instance = value.as_varint(); break; - case 2: - this->mode = static_cast(value); + case proto_tag(2, WIRE_TYPE_VARINT): + msg.mode = static_cast(value.as_varint()); break; - default: - return false; } - return true; +} +uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.vendor_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + return pos; +} +uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.vendor_id); + size += ProtoSize::calc_uint32(1, msg.product_id); + size += ProtoSize::calc_uint32(1, msg.bcd_device); + size += ProtoSize::calc_uint32(1, msg.interface_number); + return size; +} +uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.instance); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(msg.source)); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.flags); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.manufacturer); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.product); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.serial_number); + pos = ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, 7, msg.usb); + return pos; +} +uint32_t SerialProxyIdentity::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, msg.instance); + size += msg.source ? 2 : 0; + size += ProtoSize::calc_uint32(1, msg.flags); + size += ProtoSize::calc_length(1, msg.manufacturer.size()); + size += ProtoSize::calc_length(1, msg.product.size()); + size += ProtoSize::calc_length(1, msg.serial_number.size()); + size += ProtoSize::calc_message(1, msg.usb.calculate_size()); + return size; } #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { - switch (field_id) { - case 1: - this->address = value; +void BluetoothSetConnectionParamsRequest::decode_field(void *self, uint32_t tag, const uint8_t *data, + proto_varint_value_t scalar) { + auto &msg = *static_cast(self); + const ProtoFieldValue value(data, scalar); + switch (tag) { + case proto_tag(1, WIRE_TYPE_VARINT): + msg.address = value.as_varint(); break; - case 2: - this->min_interval = value; + case proto_tag(2, WIRE_TYPE_VARINT): + msg.min_interval = value.as_varint(); break; - case 3: - this->max_interval = value; + case proto_tag(3, WIRE_TYPE_VARINT): + msg.max_interval = value.as_varint(); break; - case 4: - this->latency = value; + case proto_tag(4, WIRE_TYPE_VARINT): + msg.latency = value.as_varint(); break; - case 5: - this->timeout = value; + case proto_tag(5, WIRE_TYPE_VARINT): + msg.timeout = value.as_varint(); break; - default: - return false; } - return true; } -uint8_t *BluetoothSetConnectionParamsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { - uint8_t *__restrict__ pos = buffer.get_pos(); - ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, this->address); - ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->error); +uint8_t *BluetoothSetConnectionParamsResponse::encode_msg(const void *self, + uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { + const auto &msg = *static_cast(self); + pos = ProtoEncode::encode_uint64(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.address); + pos = ProtoEncode::encode_int32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.error); return pos; } -uint32_t BluetoothSetConnectionParamsResponse::calculate_size() const { +uint32_t BluetoothSetConnectionParamsResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); uint32_t size = 0; - size += ProtoSize::calc_uint64(1, this->address); - size += ProtoSize::calc_int32(1, this->error); + size += ProtoSize::calc_uint64(1, msg.address); + size += ProtoSize::calc_int32(1, msg.error); return size; } #endif +#ifdef USE_SENDSPIN +uint8_t *SendspinPairingTokenResponse::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.status)); + pos = ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.token); + return pos; +} +uint32_t SendspinPairingTokenResponse::calc_size_msg(const void *self) { + const auto &msg = *static_cast(self); + uint32_t size = 0; + size += msg.status ? 2 : 0; + size += !msg.token.empty() ? 2 + msg.token.size() : 0; + return size; +} +#endif +#ifndef HAS_PROTO_MESSAGE_DUMP +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_WIZARD_INPUTS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_COVER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_FAN +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_LIGHT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SWITCH +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_NOISE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_API_HOMEASSISTANT_STATES +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#ifdef USE_API_USER_DEFINED_ACTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_CAMERA +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_CLIMATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_WATER_HEATER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_NUMBER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SELECT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SIREN +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_LOCK +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BUTTON +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_MEDIA_PLAYER +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_VOICE_ASSISTANT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_ALARM_CONTROL_PANEL +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_TEXT +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_DATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_TIME +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_VALVE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_DATETIME_DATETIME +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_UPDATE +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_ZWAVE_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_IR_RF +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_SERIAL_PROXY +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +static_assert(!std::is_polymorphic_v, "decodable messages carry no vtable"); +#endif +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 799aaa27b5..14204e97aa 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -23,6 +23,7 @@ enum SerialProxyPortType : uint32_t { SERIAL_PROXY_PORT_TYPE_TTL = 0, SERIAL_PROXY_PORT_TYPE_RS232 = 1, SERIAL_PROXY_PORT_TYPE_RS485 = 2, + SERIAL_PROXY_PORT_TYPE_USB_SERIAL = 3, }; enum EntityCategory : uint32_t { ENTITY_CATEGORY_NONE = 0, @@ -371,6 +372,25 @@ 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, +}; +#ifdef USE_SENDSPIN +enum SendspinPairingTokenStatus : uint32_t { + SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY = 0, + SENDSPIN_PAIRING_TOKEN_STATUS_OK = 1, + SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED = 2, + SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED = 3, + SENDSPIN_PAIRING_TOKEN_STATUS_FAILED = 4, +}; #endif } // namespace enums @@ -382,7 +402,7 @@ class InfoResponseProtoMessage : public ProtoMessage { StringRef name{}; bool disabled_by_default{false}; #ifdef USE_ENTITY_ICON - StringRef icon{}; + StringRef icon{nullptr, 0}; // null until set, encode only #endif enums::EntityCategory entity_category{}; #ifdef USE_DEVICES @@ -417,20 +437,25 @@ class CommandProtoMessage : public ProtoDecodableMessage { class HelloRequest final : public ProtoDecodableMessage { public: static constexpr uint16_t MESSAGE_TYPE = 1; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("hello_request"); } #endif StringRef client_info{}; uint32_t api_version_major{0}; uint32_t api_version_minor{0}; +#ifdef USE_API_OUTGOING_CONNECTION + bool outgoing_connection_target{false}; +#endif + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class HelloResponse final : public ProtoMessage { public: @@ -443,8 +468,12 @@ class HelloResponse final : public ProtoMessage { uint32_t api_version_minor{0}; StringRef server_info{}; StringRef name{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -459,14 +488,21 @@ class DisconnectRequest final : public ProtoDecodableMessage { const LogString *message_name() const override { return LOG_STR("disconnect_request"); } #endif enums::DisconnectReason reason{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); }; class DisconnectResponse final : public ProtoMessage { public: @@ -512,8 +548,12 @@ class AreaInfo final : public ProtoMessage { public: uint32_t area_id{0}; StringRef name{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -527,8 +567,12 @@ class DeviceInfo final : public ProtoMessage { uint32_t device_id{0}; StringRef name{}; uint32_t area_id{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -539,11 +583,15 @@ class DeviceInfo final : public ProtoMessage { #ifdef USE_SERIAL_PROXY class SerialProxyInfo final : public ProtoMessage { public: - StringRef name{}; + StringRef name{nullptr, 0}; // null until set, encode only enums::SerialProxyPortType port_type{}; uint32_t configured_line_states{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -554,7 +602,7 @@ class SerialProxyInfo final : public ProtoMessage { class DeviceInfoResponse final : public ProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 10; - static constexpr uint16_t ESTIMATED_SIZE = 312; + static constexpr uint16_t ESTIMATED_SIZE = 315; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } #endif @@ -613,8 +661,15 @@ class DeviceInfoResponse final : public ProtoMessage { #ifdef USE_API_NOISE bool api_encryption_provisionable{false}; #endif - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; +#ifdef USE_API_OUTGOING_CONNECTION + bool api_outgoing_connection_supported{false}; +#endif + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -626,8 +681,12 @@ class BluetoothProxyCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; StringRef mac_address{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -639,8 +698,12 @@ class BluetoothProxyCapabilities final : public ProtoMessage { class VoiceAssistantCapabilities final : public ProtoMessage { public: uint32_t feature_flags{0}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -653,8 +716,46 @@ 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 + + protected: +}; +#endif +#ifdef USE_SENDSPIN +class SendspinCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{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 @@ -665,7 +766,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 = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } #endif @@ -681,14 +782,68 @@ 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 +#ifdef USE_SENDSPIN + SendspinCapabilities sendspin{}; +#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; @@ -710,10 +865,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 @@ -729,8 +888,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 @@ -749,10 +912,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 @@ -769,8 +936,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 @@ -789,13 +960,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 @@ -811,8 +984,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 @@ -830,9 +1007,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 @@ -856,14 +1037,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 @@ -877,9 +1059,13 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { const light::ColorModeMask *supported_color_modes{}; 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; + const light::LightEffectNames *effects{}; + 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 @@ -904,9 +1090,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 @@ -946,14 +1136,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 @@ -964,13 +1155,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 @@ -986,8 +1181,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 @@ -1004,9 +1203,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 @@ -1016,13 +1219,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 @@ -1037,13 +1245,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 @@ -1054,9 +1264,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 @@ -1070,10 +1284,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 @@ -1090,12 +1308,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: @@ -1111,8 +1332,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 @@ -1129,12 +1354,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: @@ -1144,8 +1372,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 @@ -1156,10 +1388,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 @@ -1173,7 +1409,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{}; @@ -1185,10 +1421,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 @@ -1211,13 +1451,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 @@ -1228,11 +1470,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 @@ -1249,12 +1495,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 { @@ -1278,12 +1527,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: @@ -1291,13 +1543,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: @@ -1309,27 +1563,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 @@ -1343,15 +1603,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 @@ -1375,9 +1639,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: @@ -1400,9 +1662,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 @@ -1415,13 +1675,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 @@ -1437,8 +1701,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 @@ -1459,8 +1727,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 @@ -1476,12 +1748,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 @@ -1511,8 +1786,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 @@ -1522,7 +1801,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 @@ -1534,13 +1813,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 @@ -1574,14 +1858,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 @@ -1598,8 +1883,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 @@ -1609,7 +1898,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 @@ -1619,8 +1908,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 @@ -1640,13 +1934,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 @@ -1660,11 +1956,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 @@ -1680,8 +1980,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 @@ -1696,13 +2000,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 @@ -1713,9 +2019,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 @@ -1729,10 +2039,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 @@ -1747,14 +2061,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 @@ -1768,8 +2083,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 @@ -1784,8 +2103,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 @@ -1807,14 +2130,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 @@ -1828,9 +2152,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 @@ -1845,8 +2173,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 @@ -1863,14 +2195,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 @@ -1881,9 +2214,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 @@ -1897,25 +2234,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 @@ -1931,8 +2274,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 @@ -1949,8 +2296,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 @@ -1972,14 +2323,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 @@ -1991,12 +2343,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: @@ -2020,8 +2375,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 @@ -2041,12 +2400,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: @@ -2059,8 +2421,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 @@ -2075,20 +2441,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 @@ -2102,8 +2475,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 @@ -2116,8 +2493,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 @@ -2133,8 +2514,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 @@ -2149,8 +2534,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 @@ -2166,12 +2555,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: @@ -2188,8 +2580,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 @@ -2208,13 +2604,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: @@ -2225,12 +2623,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: @@ -2243,13 +2644,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: @@ -2261,12 +2664,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: @@ -2283,8 +2689,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 @@ -2301,8 +2711,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 @@ -2319,8 +2733,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 @@ -2336,8 +2754,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 @@ -2353,8 +2775,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 @@ -2371,8 +2797,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 @@ -2389,8 +2819,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 @@ -2407,8 +2841,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 @@ -2427,8 +2865,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 @@ -2443,12 +2885,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 @@ -2461,20 +2906,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 @@ -2489,12 +2941,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 @@ -2510,23 +2966,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: @@ -2537,13 +2999,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: @@ -2557,15 +3021,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: @@ -2580,13 +3050,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: @@ -2599,13 +3071,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: @@ -2615,8 +3089,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 @@ -2625,11 +3103,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 @@ -2645,13 +3127,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: @@ -2661,12 +3145,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: @@ -2678,8 +3165,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 @@ -2694,12 +3185,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 @@ -2713,8 +3207,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 @@ -2729,8 +3227,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 @@ -2746,14 +3248,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 @@ -2766,10 +3269,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 @@ -2783,10 +3290,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 @@ -2801,14 +3312,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 @@ -2819,8 +3331,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 @@ -2838,8 +3354,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 @@ -2856,13 +3376,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 @@ -2873,8 +3395,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 @@ -2892,8 +3418,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 @@ -2910,13 +3440,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 @@ -2927,10 +3459,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{}; - const FixedVector *event_types{}; - 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 + const ConstVector *event_types{}; + 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 @@ -2944,9 +3480,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 @@ -2962,12 +3502,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 @@ -2983,8 +3527,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 @@ -3001,13 +3549,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 @@ -3018,8 +3568,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 @@ -3035,8 +3589,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 @@ -3051,13 +3609,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 @@ -3068,9 +3628,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 @@ -3088,13 +3652,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 @@ -3109,13 +3677,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 @@ -3128,14 +3698,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: @@ -3147,15 +3724,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: @@ -3166,8 +3749,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 @@ -3185,8 +3772,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 @@ -3194,7 +3785,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; @@ -3212,14 +3803,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: @@ -3233,8 +3825,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 @@ -3254,8 +3874,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 @@ -3277,12 +3901,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: @@ -3298,8 +3925,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 @@ -3316,13 +3947,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: @@ -3333,12 +3966,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: @@ -3348,12 +3984,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: @@ -3365,8 +4004,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 @@ -3382,12 +4025,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: @@ -3399,9 +4045,13 @@ class SerialProxyRequestResponse final : public ProtoMessage { uint32_t instance{0}; enums::SerialProxyRequestType type{}; enums::SerialProxyStatus status{}; - StringRef error_message{}; - uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; - uint32_t calculate_size() const; + StringRef error_message{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif @@ -3417,12 +4067,59 @@ class SerialProxySetModeRequest final : public ProtoDecodableMessage { #endif uint32_t instance{0}; enums::SerialProxyMode mode{}; + void decode(const uint8_t *buffer, size_t length) { + ProtoDecodableMessage::decode_fields(this, buffer, length, &decode_field); + } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: + static void decode_field(void *self, uint32_t tag, const uint8_t *data, proto_varint_value_t scalar); +}; +class UsbDeviceDescriptor final : public ProtoMessage { + public: + uint32_t vendor_id{0}; + uint32_t product_id{0}; + uint32_t bcd_device{0}; + uint32_t interface_number{0}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +class SerialProxyIdentity final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 155; + static constexpr uint8_t ESTIMATED_SIZE = 54; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("serial_proxy_identity"); } +#endif + uint32_t instance{0}; + enums::SerialProxyIdentitySource source{}; + uint32_t flags{0}; + StringRef manufacturer{nullptr, 0}; // null until set, encode only + StringRef product{nullptr, 0}; // null until set, encode only + StringRef serial_number{nullptr, 0}; // null until set, encode only + UsbDeviceDescriptor usb{}; + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif protected: - bool decode_varint(uint32_t field_id, proto_varint_value_t value) override; }; #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS @@ -3438,12 +4135,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: @@ -3454,8 +4154,35 @@ 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 + + protected: +}; +#endif +#ifdef USE_SENDSPIN +class SendspinPairingTokenResponse final : public ProtoMessage { + public: + static constexpr uint16_t MESSAGE_TYPE = 160; + static constexpr uint8_t ESTIMATED_SIZE = 11; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("sendspin_pairing_token_response"); } +#endif + enums::SendspinPairingTokenStatus status{}; + StringRef token{nullptr, 0}; // null until set, encode only + static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); + } + static uint32_t calc_size_msg(const void *self); + uint32_t calculate_size() const { return calc_size_msg(this); } #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bb244973a1..3c6f5302b5 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -114,15 +114,26 @@ template static void dump_field(DumpBuffer &out, const char *field_n out.append("\n"); } +// Bytes shown by a bytes field dump: 160 bytes is 480 chars with separators, to fit a typical log buffer +static constexpr size_t DUMP_BYTES_MAX = 160; + // Helper for bytes fields - uses stack buffer to avoid heap allocation -// Buffer sized for 160 bytes of data (480 chars with separators) to fit typical log buffer // field_name is a PROGMEM pointer (flash on ESP8266, regular pointer on other platforms) static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, int indent = 2) { - char hex_buf[format_hex_pretty_size(160)]; + char hex_buf[format_hex_pretty_size(DUMP_BYTES_MAX)]; append_field_prefix(out, field_name, indent); format_hex_pretty_to(hex_buf, data, len); out.append(hex_buf).append("\n"); } + +// Helper for bytes fields in flash: copies the shown bytes out with progmem_memcpy first +static void dump_progmem_bytes_field(DumpBuffer &out, const char *field_name, const uint8_t *data, size_t len, + int indent = 2) { + uint8_t data_buf[DUMP_BYTES_MAX]; + len = std::min(len, sizeof(data_buf)); + progmem_memcpy(data_buf, data, len); + dump_bytes_field(out, field_name, data_buf, len, indent); +} #pragma GCC diagnostic pop template<> const char *proto_enum_to_string(enums::DisconnectReason value) { @@ -143,6 +154,8 @@ template<> const char *proto_enum_to_string(enums::S return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS232"); case enums::SERIAL_PROXY_PORT_TYPE_RS485: return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_RS485"); + case enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL: + return ESPHOME_PSTR("SERIAL_PROXY_PORT_TYPE_USB_SERIAL"); default: return ESPHOME_PSTR("UNKNOWN"); } @@ -890,6 +903,49 @@ template<> const char *proto_enum_to_string(enums::Seria return ESPHOME_PSTR("UNKNOWN"); } } +template<> const char *proto_enum_to_string(enums::SerialProxyIdentitySource value) { + switch (value) { + case enums::SERIAL_PROXY_IDENTITY_SOURCE_NONE: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_NONE"); + case enums::SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_CONFIGURED"); + case enums::SERIAL_PROXY_IDENTITY_SOURCE_USB: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_SOURCE_USB"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} +#endif +template<> const char *proto_enum_to_string(enums::SerialProxyIdentityFlag value) { + switch (value) { + case enums::SERIAL_PROXY_IDENTITY_FLAG_NONE: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_NONE"); + case enums::SERIAL_PROXY_IDENTITY_FLAG_CONNECTED: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_CONNECTED"); + case enums::SERIAL_PROXY_IDENTITY_FLAG_ERROR: + return ESPHOME_PSTR("SERIAL_PROXY_IDENTITY_FLAG_ERROR"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} +#ifdef USE_SENDSPIN +template<> +const char *proto_enum_to_string(enums::SendspinPairingTokenStatus value) { + switch (value) { + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_NOT_READY"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_OK: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_OK"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_ENCRYPTION_REQUIRED"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_DISABLED"); + case enums::SENDSPIN_PAIRING_TOKEN_STATUS_FAILED: + return ESPHOME_PSTR("SENDSPIN_PAIRING_TOKEN_STATUS_FAILED"); + default: + return ESPHOME_PSTR("UNKNOWN"); + } +} #endif const char *HelloRequest::dump_to(DumpBuffer &out) const { @@ -897,6 +953,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("client_info"), this->client_info); dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major); dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor); +#ifdef USE_API_OUTGOING_CONNECTION + dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target); +#endif return out.c_str(); } const char *HelloResponse::dump_to(DumpBuffer &out) const { @@ -1020,6 +1079,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(); } @@ -1046,6 +1108,20 @@ 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 +#ifdef USE_SENDSPIN +const char *SendspinCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); #ifdef USE_BLUETOOTH_PROXY @@ -1069,9 +1145,34 @@ 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 +#ifdef USE_SENDSPIN + out.append(2, ' ').append_p(ESPHOME_PSTR("sendspin")).append(": "); + this->sendspin.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_progmem_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(); @@ -1342,6 +1443,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 { @@ -1684,6 +1786,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 { @@ -1751,6 +1854,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 { @@ -2711,7 +2815,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 @@ -2740,6 +2844,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 { @@ -2823,6 +2936,27 @@ const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("mode"), static_cast(this->mode)); return out.c_str(); } +const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("UsbDeviceDescriptor")); + dump_field(out, ESPHOME_PSTR("vendor_id"), this->vendor_id); + dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); + dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); + dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + return out.c_str(); +} +const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyIdentity")); + dump_field(out, ESPHOME_PSTR("instance"), this->instance); + dump_field(out, ESPHOME_PSTR("source"), static_cast(this->source)); + dump_field(out, ESPHOME_PSTR("flags"), this->flags); + dump_field(out, ESPHOME_PSTR("manufacturer"), this->manufacturer); + dump_field(out, ESPHOME_PSTR("product"), this->product); + dump_field(out, ESPHOME_PSTR("serial_number"), this->serial_number); + out.append(2, ' ').append_p(ESPHOME_PSTR("usb")).append(": "); + this->usb.dump_to(out); + out.append("\n"); + return out.c_str(); +} #endif #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { @@ -2841,6 +2975,14 @@ const char *BluetoothSetConnectionParamsResponse::dump_to(DumpBuffer &out) const return out.c_str(); } #endif +#ifdef USE_SENDSPIN +const char *SendspinPairingTokenResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("SendspinPairingTokenResponse")); + dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status)); + dump_field(out, ESPHOME_PSTR("token"), this->token); + return out.c_str(); +} +#endif } // namespace esphome::api diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h index 70ba579fcc..e076f98fe3 100644 --- a/esphome/components/api/api_pb2_includes.h +++ b/esphome/components/api/api_pb2_includes.h @@ -15,6 +15,7 @@ #endif #ifdef USE_LIGHT +#include "esphome/components/light/light_effect_names.h" #include "esphome/components/light/light_traits.h" #endif @@ -28,6 +29,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 172062be63..683bb84c30 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -6,7 +6,7 @@ namespace esphome::api { -static const char *const TAG = "api.service"; +ESPHOME_LOG_TAG(TAG, "api.service"); #ifdef HAS_PROTO_MESSAGE_DUMP void APIServerConnectionBase::log_send_message_(const LogString *name, const char *dump) { @@ -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); @@ -722,6 +722,44 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui 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 +#ifdef USE_SENDSPIN + case 159 /* SendspinPairingTokenRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_sendspin_pairing_token_request")); +#endif + this->on_sendspin_pairing_token_request(); + break; + } #endif default: break; diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index a4dfd6a366..7f33e5668c 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -29,6 +29,13 @@ class APIServerConnectionBase { void on_device_capabilities_request(){}; +#ifdef USE_API_WIZARD + void on_device_wizard_request(){}; +#endif + +#ifdef USE_API_WIZARD_INPUTS + void on_wizard_input_set_request(const WizardInputSetRequest &value){}; +#endif void on_list_entities_request(){}; void on_subscribe_states_request(){}; @@ -213,7 +220,7 @@ class APIServerConnectionBase { void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif -#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) +#ifdef USE_IR_RF void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){}; #endif @@ -238,9 +245,17 @@ class APIServerConnectionBase { #ifdef USE_SERIAL_PROXY void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){}; #endif +#ifdef USE_SERIAL_PROXY + void on_subscribe_serial_proxy_identity_request(){}; +#endif + #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif + +#ifdef USE_SENDSPIN + void on_sendspin_pairing_token_request(){}; +#endif }; } // namespace esphome::api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index d74de98c75..f63039a971 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" @@ -24,7 +23,7 @@ namespace esphome::api { -static const char *const TAG = "api"; +ESPHOME_LOG_TAG(TAG, "api"); // APIServer APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -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,21 @@ 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; + } + } + // Generating the key pair cannot be split across loop passes + UnavoidableBlockingScope scope; + noise::prepare_spare_ephemeral(); +} +#endif + void APIServer::remove_client_(uint8_t client_index) { auto &client = this->clients_[client_index]; @@ -225,6 +258,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 +285,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 +315,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 +324,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 +381,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 +506,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 +528,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 +545,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 +690,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 +793,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 b8a71c93e7..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}; @@ -358,16 +378,21 @@ class APIServer final : public Component, // Group smaller types together uint16_t port_{6053}; // Keep in sync with DEFAULT_PORT in __init__.py uint16_t batch_delay_{100}; // Keep in sync with DEFAULT_BATCH_DELAY in __init__.py - // Connection limits - these defaults will be overridden by config values - // from cv.SplitDefault in __init__.py which sets platform-specific defaults. - uint8_t listen_backlog_{4}; + uint8_t listen_backlog_{4}; // Keep in sync with DEFAULT_LISTEN_BACKLOG in __init__.py bool shutting_down_ = false; uint8_t api_connection_count_{0}; +#ifdef USE_API_OUTGOING_CONNECTION + // Connected clients whose hello declared them a dial-back target + uint8_t outgoing_target_count_{0}; +#endif #if defined(USE_PROVISIONING) && defined(USE_API_NOISE) // Index assigned by the provisioning manager for reporting this transport's state. uint8_t provisioning_source_{0}; #endif +#ifdef USE_NOISE_SPARE_EPHEMERAL + void refill_spare_ephemeral_(); +#endif #ifdef USE_API_NOISE noise::NoiseContext noise_ctx_; #ifndef USE_API_NOISE_PSK_FROM_YAML @@ -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..9467efcfa0 --- /dev/null +++ b/esphome/components/api/api_wizard.cpp @@ -0,0 +1,128 @@ +#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 { + +ESPHOME_LOG_TAG(TAG, "api.wizard"); + +#ifdef USE_API_WIZARD_INPUTS +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +// Walks the comma separated entity ids in value, without the spaces around each. Returns the length they take joined +// by ',', or 0 when one is empty or has no '.'. Writes the joined list to out unless it is nullptr. +static size_t wizard_join_entity_ids(const char *value, size_t length, char *out) { + size_t joined = 0; + const char *end = value + length; + while (true) { + const char *comma = static_cast(memchr(value, ',', end - value)); + const char *id_end = comma != nullptr ? comma : end; + while (value < id_end && *value == ' ') + value++; + while (id_end > value && id_end[-1] == ' ') + id_end--; + size_t id_length = id_end - value; + if (id_length == 0 || memchr(value, '.', id_length) == nullptr) + return 0; + if (joined != 0) { + if (out != nullptr) + out[joined] = ','; + joined++; + } + if (out != nullptr) + memcpy(out + joined, value, id_length); + joined += id_length; + if (comma == nullptr) + return joined; + value = comma + 1; + } +} +#endif // USE_API_WIZARD_MULTIPLE_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) { + const char *value = msg.entity_id.c_str(); + const size_t length = msg.entity_id.size(); + 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; +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + if (entry.size != 0) { + // Two passes, validate and measure then write, so a rejected request leaves the buffer unchanged + const size_t joined = wizard_join_entity_ids(value, length, nullptr); + if (joined == 0 || joined >= entry.size) + break; + wizard_join_entity_ids(value, length, entry.entity_id); + entry.entity_id[joined] = '\0'; + return entry.entity_id; + } +#endif + if (!wizard_entity_id_valid(value, length)) + break; + memcpy(entry.entity_id, value, length); + entry.entity_id[length] = '\0'; + return entry.entity_id; + } + ESP_LOGW(TAG, "Ignoring an entity id for an unknown wizard input, or one that is not valid"); + return nullptr; +} +#endif // USE_API_WIZARD_INPUTS + +void APIConnection::on_device_wizard_request() { + DeviceWizardResponse resp; + resp.data = API_WIZARD_DATA; + resp.data_len = API_WIZARD_DATA_SIZE; + if (!this->send_message(resp)) { + 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..6bdc1654bc --- /dev/null +++ b/esphome/components/api/api_wizard.h @@ -0,0 +1,89 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef USE_API_WIZARD + +#include +#include +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS +#include +#endif + +#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; + +#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, WIZARD_ENTITY_ID_BUFFER_SIZE bytes or size, shared with the homeassistant entity +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + uint16_t size; // Buffer size of a multiple input, 0 for a single input +#endif +}; + +/// 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. For a multiple input this is the whole + /// comma separated list, which homeassistant.action data can use as it is. + StringRef entity_id() const { return StringRef(this->entity_id_); } + bool has_entity_id() const { return this->entity_id_[0] != '\0'; } +#ifdef USE_API_WIZARD_MULTIPLE_INPUTS + /// The number of entity IDs in the list, 0 until the wizard sets one. + size_t entity_id_count() const { + if (!this->has_entity_id()) + return 0; + size_t count = 1; + for (const char *p = this->entity_id_; (p = strchr(p, ',')) != nullptr; p++) + count++; + return count; + } + /// Call callback(StringRef) for each entity ID in the list, in order. The StringRef points into the list and is + /// not null terminated, so print it with "%.*s". + template void for_each_entity_id(F &&callback) const { + // The device stores the list without spaces or empty entries + const char *start = this->entity_id_; + while (*start != '\0') { + const char *end = strchr(start, ','); + if (end == nullptr) + end = start + strlen(start); + callback(StringRef(start, end - start)); + start = *end == ',' ? end + 1 : end; + } + } +#endif + + 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..e808e8a9ec 100644 --- a/esphome/components/api/proto.cpp +++ b/esphome/components/api/proto.cpp @@ -6,7 +6,7 @@ namespace esphome::api { -static const char *const TAG = "api.proto"; +ESPHOME_LOG_TAG(TAG, "api.proto"); uint32_t ProtoSize::varint_slow(uint32_t value) { return varint_wide(value); } @@ -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..0fdda5067e 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -3,6 +3,7 @@ #include "api_pb2_defines.h" #include "api_buffer.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/progmem.h" @@ -10,6 +11,7 @@ #include #include +#include #include #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE @@ -55,22 +57,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 +175,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 +229,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 +265,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 +276,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 +284,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 +319,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 +376,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 +416,220 @@ 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); + } + /// encode_bytes for data in flash (PROGMEM), which ESP8266 can only read with progmem_memcpy. + [[nodiscard]] static inline uint8_t *encode_progmem_bytes(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + if (len == 0) + return pos; + return encode_progmem_bytes_force(pos PROTO_ENCODE_DEBUG_ARG, field_id, data, len); + } + [[nodiscard]] static inline uint8_t *encode_progmem_bytes_force(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, + uint32_t field_id, const uint8_t *data, size_t len) { + pos = encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited + pos = encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, len); + PROTO_ENCODE_CHECK_BOUNDS(pos, len); + progmem_memcpy(pos, data, len); + return pos + 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 +721,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 +744,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 +762,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 +895,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 +977,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..8e5c808e24 --- /dev/null +++ b/esphome/components/api/wizard.py @@ -0,0 +1,499 @@ +"""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 typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_DESCRIPTION +from esphome.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +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_MAX_LENGTH, + CONF_MULTIPLE, + 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, + zstd_module, +) +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 +) +WIZARD_ENTITY_ID_MAX_LENGTH = WIZARD_ENTITY_ID_BUFFER_SIZE - 1 +# The value of a multiple input fits one WizardInputSetRequest, which the device accepts up to 8 KiB on ESP8266; +# api.proto max_data_length documents the same value +WIZARD_MULTIPLE_MAX_LENGTH = 4095 +# 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), + } +) + + +def _validate_multiple(conf: ConfigType) -> ConfigType: + if conf[CONF_MULTIPLE] and CONF_ENTITY in conf: + raise cv.Invalid( + "A linked input follows the state of exactly one Home Assistant entity, so multiple " + "is only for standalone inputs (id)", + path=[CONF_MULTIPLE], + ) + if CONF_MAX_LENGTH in conf and not conf[CONF_MULTIPLE]: + raise cv.Invalid( + "max_length only applies to an input with multiple: true", + path=[CONF_MAX_LENGTH], + ) + return conf + + +# 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_MULTIPLE, default=False): cv.boolean, + cv.Optional(CONF_MAX_LENGTH): cv.int_range( + min=WIZARD_ENTITY_ID_MAX_LENGTH, max=WIZARD_MULTIPLE_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), + _validate_multiple, +) + +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") + if any(conf.get(CONF_MULTIPLE) for conf in inputs): + defines.add("USE_API_WIZARD_MULTIPLE_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_DOMAIN + 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 _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 conf.get(CONF_MULTIPLE): + document["multiple"] = True + if (max_length := conf.get(CONF_MAX_LENGTH)) is not None: + document["max_length"] = max_length + 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": "...", "multiple": true, "max_length": 1023, + "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. + - multiple means the user may pick several entities. The client sends them in one + WizardInputSetRequest as a comma separated list ("calendar.a,calendar.b") with no spaces; the + device trims spaces around each id and stores the list without them. The whole request is + ignored when any id is empty or has no '.', or when the list without spaces does not fit + max_length (only on a multiple input, 255 when left out). A single input is ignored when the id + is empty, longer than 255 bytes or has no '.'. The version stays 1: a client that does not know + multiple ignores it, shows a single picker and sends one entity id, which a multiple input + accepts. + - 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) + size = conf.get(CONF_MAX_LENGTH, WIZARD_ENTITY_ID_MAX_LENGTH) + 1 + cg.add_global(cg.RawStatement(f'static char {buffer}[{size}] = "";')) + if CONF_ID in conf: + cg.new_Pvariable(input_id, cg.RawExpression(buffer)) + key = fnv1_hash(input_id.id) + if conf[CONF_MULTIPLE]: + entries.append(cg.RawExpression(f"{{{key}u, {buffer}, {size}}}")) + else: + entries.append(cg.RawExpression(f"{{{key}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/aqi/__init__.py b/esphome/components/aqi/__init__.py index 17d434294a..56308e3298 100644 --- a/esphome/components/aqi/__init__.py +++ b/esphome/components/aqi/__init__.py @@ -1,11 +1,12 @@ import esphome.codegen as cg +from esphome.components.const import CONF_AQI # noqa: F401 CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"] +DOMAIN = "aqi" aqi_ns = cg.esphome_ns.namespace("aqi") AQICalculatorType = aqi_ns.enum("AQICalculatorType") -CONF_AQI = "aqi" CONF_CALCULATION_TYPE = "calculation_type" CONF_EXTENDED_RANGE = "extended_range" diff --git a/esphome/components/aqi/aqi_sensor.cpp b/esphome/components/aqi/aqi_sensor.cpp index e78f301b30..c242ee3913 100644 --- a/esphome/components/aqi/aqi_sensor.cpp +++ b/esphome/components/aqi/aqi_sensor.cpp @@ -3,20 +3,22 @@ namespace esphome::aqi { -static const char *const TAG = "aqi"; +ESPHOME_LOG_TAG(TAG, "aqi"); + +static constexpr uint32_t CALCULATE_DEFER_ID = 0; void AQISensor::setup() { if (this->pm_2_5_sensor_ != nullptr) { this->pm_2_5_sensor_->add_on_state_callback([this](float value) { this->pm_2_5_value_ = value; // Defer calculation to avoid double-publishing if both sensors update in the same loop - this->defer("update", [this]() { this->calculate_aqi_(); }); + this->defer(CALCULATE_DEFER_ID, [this]() { this->calculate_aqi_(); }); }); } if (this->pm_10_0_sensor_ != nullptr) { this->pm_10_0_sensor_->add_on_state_callback([this](float value) { this->pm_10_0_value_ = value; - this->defer("update", [this]() { this->calculate_aqi_(); }); + this->defer(CALCULATE_DEFER_ID, [this]() { this->calculate_aqi_(); }); }); } } diff --git a/esphome/components/aranet/__init__.py b/esphome/components/aranet/__init__.py new file mode 100644 index 0000000000..d1d75568e9 --- /dev/null +++ b/esphome/components/aranet/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@patrickod"] +DOMAIN = "aranet" diff --git a/esphome/components/aranet/aranet.cpp b/esphome/components/aranet/aranet.cpp new file mode 100644 index 0000000000..4fa28eec17 --- /dev/null +++ b/esphome/components/aranet/aranet.cpp @@ -0,0 +1,157 @@ +#include "aranet.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::aranet { + +ESPHOME_LOG_TAG(TAG, "aranet"); +static constexpr uint16_t ARANET_MANUFACTURER_ID = 0x0702; +static constexpr size_t ARANET4_DATA_LENGTH = 22; +static constexpr size_t OTHER_ARANET_DATA_LENGTH = 24; + +enum class AranetDeviceType : uint8_t { + ARANET_DEVICE_TYPE_ARANET4 = 0, + ARANET_DEVICE_TYPE_ARANET2 = 1, + ARANET_DEVICE_TYPE_RADIATION = 2, + ARANET_DEVICE_TYPE_RADON = 3, +}; + +static inline void publish_if_set(sensor::Sensor *sensor, float value) { + if (sensor != nullptr) + sensor->publish_state(value); +} + +// Aranet does not publish an official advertisement specification. The layout below is corroborated by these +// independently maintained implementations: +// https://github.com/Anrijs/Aranet4-Python/blob/master/aranet4/client.py +// https://github.com/Anrijs/Aranet4-ESP32/blob/main/src/Aranet4.h + +void Aranet::dump_config() { + ESP_LOGCONFIG(TAG, "Aranet"); + LOG_SENSOR(" ", "Carbon Dioxide", this->co2_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "Pressure", this->pressure_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Measurement Interval", this->measurement_interval_sensor_); + LOG_SENSOR(" ", "Measurement Age", this->measurement_age_sensor_); + LOG_SENSOR(" ", "Signal Strength", this->signal_strength_sensor_); + LOG_SENSOR(" ", "Radon Concentration", this->radon_sensor_); + LOG_SENSOR(" ", "Radiation Dose Rate", this->radiation_rate_sensor_); + LOG_SENSOR(" ", "Radiation Total Dose", this->radiation_total_sensor_); + LOG_SENSOR(" ", "Radiation Dose Duration", this->radiation_duration_sensor_); +} + +bool Aranet::parse_device(const ble_device_base::ESPBTDevice &device) { + if (device.address_uint64() != this->address_) + return false; + + const auto manufacturer_uuid = ble_device_base::ESPBTUUID::from_uint16(ARANET_MANUFACTURER_ID); + for (const auto &manufacturer_data : device.get_manufacturer_datas()) { + if (manufacturer_data.uuid != manufacturer_uuid) + continue; + + const auto &data = manufacturer_data.data; + if (data.size() != ARANET4_DATA_LENGTH && data.size() != OTHER_ARANET_DATA_LENGTH) { + if ((this->warned_ & WARNED_INVALID_LENGTH) == 0) { + ESP_LOGW(TAG, "Unexpected manufacturer data length: %u", static_cast(data.size())); + this->warned_ |= WARNED_INVALID_LENGTH; + } + return false; + } + + // Aranet4 has no device type byte, so its flags and trailer start one byte earlier + const bool is_aranet4 = data.size() == ARANET4_DATA_LENGTH; + if ((data[is_aranet4 ? 0 : 1] & 0x20) == 0) { + if ((this->warned_ & WARNED_INTEGRATION_DISABLED) == 0) { + ESP_LOGW(TAG, "Smart Home integration is disabled on the Aranet device"); + this->warned_ |= WARNED_INTEGRATION_DISABLED; + } + return false; + } + + if (!is_aranet4 && (data[0] == 0 || data[0] > 3)) { + if ((this->warned_ & WARNED_UNSUPPORTED_TYPE) == 0) { + ESP_LOGW(TAG, "Unsupported Aranet device type: %u", data[0]); + this->warned_ |= WARNED_UNSUPPORTED_TYPE; + } + return false; + } + const auto device_type = + is_aranet4 ? AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4 : static_cast(data[0]); + + // Battery, measurement interval, measurement age and measurement counter + const uint8_t *trailer = &data[is_aranet4 ? 15 : 17]; + const uint16_t measurement_interval = encode_uint16(trailer[3], trailer[2]); + const uint16_t measurement_age = encode_uint16(trailer[5], trailer[4]); + const uint8_t measurement_counter = trailer[6]; + + publish_if_set(this->measurement_age_sensor_, measurement_age); + publish_if_set(this->signal_strength_sensor_, device.get_rssi()); + + if (measurement_counter == this->last_measurement_counter_) + return true; + this->last_measurement_counter_ = measurement_counter; + + // Not meaningful for Aranet Radiation, which stores its dose duration in these bytes + const float temperature = encode_uint16(data[11], data[10]) / 20.0f; + const float pressure = encode_uint16(data[13], data[12]) / 10.0f; + switch (device_type) { + case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET4: { + const uint16_t co2 = encode_uint16(data[9], data[8]); + publish_if_set(this->co2_sensor_, co2); + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->pressure_sensor_, pressure); + publish_if_set(this->humidity_sensor_, data[14]); + ESP_LOGD(TAG, "Aranet4 CO2: %u ppm, temperature: %.1f C, humidity: %u%%, pressure: %.1f hPa", co2, temperature, + data[14], pressure); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_ARANET2: { + const float humidity = encode_uint16(data[15], data[14]) / 10.0f; + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->humidity_sensor_, humidity); + ESP_LOGD(TAG, "Aranet2 temperature: %.1f C, humidity: %.1f%%", temperature, humidity); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_RADIATION: { + const uint32_t radiation_total = encode_uint32(data[9], data[8], data[7], data[6]); + const uint32_t radiation_duration = encode_uint32(data[13], data[12], data[11], data[10]); + const uint16_t radiation_rate = encode_uint16(data[15], data[14]); + publish_if_set(this->radiation_rate_sensor_, radiation_rate / 1000.0f); + publish_if_set(this->radiation_total_sensor_, radiation_total); + publish_if_set(this->radiation_duration_sensor_, radiation_duration); + ESP_LOGD(TAG, "Aranet Radiation dose rate: %u nSv/h, total dose: %" PRIu32 " nSv", radiation_rate, + radiation_total); + break; + } + case AranetDeviceType::ARANET_DEVICE_TYPE_RADON: { + const uint16_t radon = encode_uint16(data[9], data[8]); + const float humidity = encode_uint16(data[15], data[14]) / 10.0f; + publish_if_set(this->radon_sensor_, radon); + // Aranet Radon One uses zero temperature and humidity to indicate fields not supported by that model. + if (temperature != 0) + publish_if_set(this->temperature_sensor_, temperature); + publish_if_set(this->pressure_sensor_, pressure); + if (humidity != 0) + publish_if_set(this->humidity_sensor_, humidity); + ESP_LOGD(TAG, "Aranet Radon: %u Bq/m3, temperature: %.1f C, humidity: %.1f%%, pressure: %.1f hPa", radon, + temperature, humidity, pressure); + break; + } + default: + break; + } + publish_if_set(this->battery_level_sensor_, trailer[0]); + publish_if_set(this->measurement_interval_sensor_, measurement_interval); + + return true; + } + + return false; +} + +} // namespace esphome::aranet diff --git a/esphome/components/aranet/aranet.h b/esphome/components/aranet/aranet.h new file mode 100644 index 0000000000..7064ad7d45 --- /dev/null +++ b/esphome/components/aranet/aranet.h @@ -0,0 +1,42 @@ +#pragma once + +#include "esphome/components/ble_device_base/ble_device.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::aranet { + +class Aranet final : public Component, public ble_device_base::ESPBTDeviceListener { + public: + explicit Aranet(uint64_t address) : address_(address) {} + + bool parse_device(const ble_device_base::ESPBTDevice &device) override; + void dump_config() override; + + protected: + static constexpr uint8_t WARNED_INVALID_LENGTH = 1 << 0; + static constexpr uint8_t WARNED_INTEGRATION_DISABLED = 1 << 1; + static constexpr uint8_t WARNED_UNSUPPORTED_TYPE = 1 << 2; + + // Small state first so it fills the padding before the first pointer + uint16_t last_measurement_counter_{0x100}; // Out of range of the 8-bit counter until the first measurement + uint8_t warned_{0}; + + SUB_SENSOR(co2) + SUB_SENSOR(temperature) + SUB_SENSOR(humidity) + SUB_SENSOR(pressure) + SUB_SENSOR(battery_level) + SUB_SENSOR(measurement_interval) + SUB_SENSOR(measurement_age) + SUB_SENSOR(signal_strength) + SUB_SENSOR(radon) + SUB_SENSOR(radiation_rate) + SUB_SENSOR(radiation_total) + SUB_SENSOR(radiation_duration) + + protected: + uint64_t address_; +}; + +} // namespace esphome::aranet diff --git a/esphome/components/aranet/sensor.py b/esphome/components/aranet/sensor.py new file mode 100644 index 0000000000..f22d242f9c --- /dev/null +++ b/esphome/components/aranet/sensor.py @@ -0,0 +1,150 @@ +import esphome.codegen as cg +from esphome.components import ble_device_base, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CO2, + CONF_HUMIDITY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_PRESSURE, + CONF_RADON, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_RADON, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_RADIOACTIVE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, + UNIT_BECQUEREL_PER_CUBIC_METER, + UNIT_CELSIUS, + UNIT_DECIBEL_MILLIWATT, + UNIT_HECTOPASCAL, + UNIT_MICROSILVERTS_PER_HOUR, + UNIT_PARTS_PER_MILLION, + UNIT_PERCENT, + UNIT_SECOND, +) +from esphome.types import ConfigType + +AUTO_LOAD = ["ble_device_base"] + +CONF_MEASUREMENT_AGE = "measurement_age" +CONF_MEASUREMENT_INTERVAL = "measurement_interval" +CONF_RADIATION_RATE = "radiation_rate" +CONF_RADIATION_TOTAL = "radiation_total" +CONF_RADIATION_DURATION = "radiation_duration" +UNIT_NANOSIEVERT = "nSv" + +aranet_ns = cg.esphome_ns.namespace("aranet") +Aranet = aranet_ns.class_("Aranet", ble_device_base.ESPBTDeviceListener, cg.Component) + +DIAGNOSTIC_DURATION_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Aranet), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + 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, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_PRESSURE): sensor.sensor_schema( + unit_of_measurement=UNIT_HECTOPASCAL, + accuracy_decimals=1, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + 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, + ), + cv.Optional(CONF_MEASUREMENT_INTERVAL): DIAGNOSTIC_DURATION_SCHEMA, + cv.Optional(CONF_MEASUREMENT_AGE): DIAGNOSTIC_DURATION_SCHEMA, + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RADON): sensor.sensor_schema( + unit_of_measurement=UNIT_BECQUEREL_PER_CUBIC_METER, + accuracy_decimals=0, + device_class=DEVICE_CLASS_RADON, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADIATION_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_MICROSILVERTS_PER_HOUR, + accuracy_decimals=3, + icon=ICON_RADIOACTIVE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_RADIATION_TOTAL): sensor.sensor_schema( + unit_of_measurement=UNIT_NANOSIEVERT, + accuracy_decimals=0, + icon=ICON_RADIOACTIVE, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_RADIATION_DURATION): sensor.sensor_schema( + unit_of_measurement=UNIT_SECOND, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + } + ) + .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) + await cg.register_component(var, config) + await ble_device_base.register_ble_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) + await sensors(CONF_PRESSURE, var.set_pressure_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_MEASUREMENT_INTERVAL, var.set_measurement_interval_sensor) + await sensors(CONF_MEASUREMENT_AGE, var.set_measurement_age_sensor) + await sensors(CONF_SIGNAL_STRENGTH, var.set_signal_strength_sensor) + await sensors(CONF_RADON, var.set_radon_sensor) + await sensors(CONF_RADIATION_RATE, var.set_radiation_rate_sensor) + await sensors(CONF_RADIATION_TOTAL, var.set_radiation_total_sensor) + await sensors(CONF_RADIATION_DURATION, var.set_radiation_duration_sensor) diff --git a/esphome/components/as3935/__init__.py b/esphome/components/as3935/__init__.py index bd02d22d1b..b7b5b09c62 100644 --- a/esphome/components/as3935/__init__.py +++ b/esphome/components/as3935/__init__.py @@ -17,6 +17,7 @@ from esphome.const import ( from esphome.cpp_generator import MockObj from esphome.types import ConfigType +DOMAIN = "as3935" MULTI_CONF = True CONF_AS3935_ID = "as3935_id" diff --git a/esphome/components/as3935/as3935.cpp b/esphome/components/as3935/as3935.cpp index a1d74c78de..00ac7ef365 100644 --- a/esphome/components/as3935/as3935.cpp +++ b/esphome/components/as3935/as3935.cpp @@ -3,7 +3,7 @@ namespace esphome::as3935 { -static const char *const TAG = "as3935"; +ESPHOME_LOG_TAG(TAG, "as3935"); void AS3935Component::setup() { this->irq_pin_->setup(); 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/as3935_i2c/__init__.py b/esphome/components/as3935_i2c/__init__.py index 83924de760..e1d2d89ec5 100644 --- a/esphome/components/as3935_i2c/__init__.py +++ b/esphome/components/as3935_i2c/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["as3935"] DEPENDENCIES = ["i2c"] +DOMAIN = "as3935_i2c" as3935_i2c_ns = cg.esphome_ns.namespace("as3935_i2c") I2CAS3935 = as3935_i2c_ns.class_("I2CAS3935Component", as3935.AS3935, i2c.I2CDevice) diff --git a/esphome/components/as3935_i2c/as3935_i2c.cpp b/esphome/components/as3935_i2c/as3935_i2c.cpp index b3d015114f..06f816182e 100644 --- a/esphome/components/as3935_i2c/as3935_i2c.cpp +++ b/esphome/components/as3935_i2c/as3935_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::as3935_i2c { -static const char *const TAG = "as3935_i2c"; +ESPHOME_LOG_TAG(TAG, "as3935_i2c"); void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits, uint8_t start_pos) { uint8_t write_reg; diff --git a/esphome/components/as3935_spi/__init__.py b/esphome/components/as3935_spi/__init__.py index 332a51c7a9..9313705ab6 100644 --- a/esphome/components/as3935_spi/__init__.py +++ b/esphome/components/as3935_spi/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["as3935"] DEPENDENCIES = ["spi"] +DOMAIN = "as3935_spi" as3935_spi_ns = cg.esphome_ns.namespace("as3935_spi") SPIAS3935 = as3935_spi_ns.class_("SPIAS3935Component", as3935.AS3935, spi.SPIDevice) diff --git a/esphome/components/as3935_spi/as3935_spi.cpp b/esphome/components/as3935_spi/as3935_spi.cpp index 026fde2f21..f966126357 100644 --- a/esphome/components/as3935_spi/as3935_spi.cpp +++ b/esphome/components/as3935_spi/as3935_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::as3935_spi { -static const char *const TAG = "as3935_spi"; +ESPHOME_LOG_TAG(TAG, "as3935_spi"); void SPIAS3935Component::setup() { this->spi_setup(); diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py index 780712c3bd..3cc5ed5cc1 100644 --- a/esphome/components/as5600/__init__.py +++ b/esphome/components/as5600/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@ammmze"] DEPENDENCIES = ["i2c"] +DOMAIN = "as5600" MULTI_CONF = True as5600_ns = cg.esphome_ns.namespace("as5600") diff --git a/esphome/components/as5600/as5600.cpp b/esphome/components/as5600/as5600.cpp index da1add5458..fc768b383b 100644 --- a/esphome/components/as5600/as5600.cpp +++ b/esphome/components/as5600/as5600.cpp @@ -3,7 +3,7 @@ namespace esphome::as5600 { -static const char *const TAG = "as5600"; +ESPHOME_LOG_TAG(TAG, "as5600"); // Configuration registers static const uint8_t REGISTER_ZMCO = 0x00; // 8 bytes / R 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/as5600/sensor/as5600_sensor.cpp b/esphome/components/as5600/sensor/as5600_sensor.cpp index ba295659a1..88884805a0 100644 --- a/esphome/components/as5600/sensor/as5600_sensor.cpp +++ b/esphome/components/as5600/sensor/as5600_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::as5600 { -static const char *const TAG = "as5600.sensor"; +ESPHOME_LOG_TAG(TAG, "as5600.sensor"); // Configuration registers static const uint8_t REGISTER_ZMCO = 0x00; // 8 bytes / R diff --git a/esphome/components/as7341/__init__.py b/esphome/components/as7341/__init__.py index e69de29bb2..bdbe78a4ec 100644 --- a/esphome/components/as7341/__init__.py +++ b/esphome/components/as7341/__init__.py @@ -0,0 +1 @@ +DOMAIN = "as7341" diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp index 32094ddf10..359636a499 100644 --- a/esphome/components/as7341/as7341.cpp +++ b/esphome/components/as7341/as7341.cpp @@ -4,7 +4,7 @@ namespace esphome::as7341 { -static const char *const TAG = "as7341"; +ESPHOME_LOG_TAG(TAG, "as7341"); void AS7341Component::setup() { LOG_I2C_DEVICE(this); diff --git a/esphome/components/async_tcp/__init__.py b/esphome/components/async_tcp/__init__.py index 31007022d5..b67202db59 100644 --- a/esphome/components/async_tcp/__init__.py +++ b/esphome/components/async_tcp/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +DOMAIN = "async_tcp" def AUTO_LOAD() -> list[str]: diff --git a/esphome/components/async_tcp/async_tcp_socket.cpp b/esphome/components/async_tcp/async_tcp_socket.cpp index 10cbc981c7..03191f6e37 100644 --- a/esphome/components/async_tcp/async_tcp_socket.cpp +++ b/esphome/components/async_tcp/async_tcp_socket.cpp @@ -6,11 +6,10 @@ #include "esphome/components/network/util.h" #include "esphome/core/log.h" #include -#include namespace esphome::async_tcp { -static const char *const TAG = "async_tcp"; +ESPHOME_LOG_TAG(TAG, "async_tcp"); // Read buffer size matches TCP MSS (1500 MTU - 40 bytes IP/TCP headers). // This implementation only runs on ESP-IDF and host which have ample stack. @@ -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 a29e166745..50c9080c2d 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@X-Ryl669"] DEPENDENCIES = ["i2c"] +DOMAIN = "at581x" MULTI_CONF = True diff --git a/esphome/components/at581x/at581x.cpp b/esphome/components/at581x/at581x.cpp index 575a9afd40..618e4bee5b 100644 --- a/esphome/components/at581x/at581x.cpp +++ b/esphome/components/at581x/at581x.cpp @@ -51,7 +51,7 @@ const uint8_t SELF_CHECK_TIME_ADDR = 0x38; // 2 bytes, up to 0x39 namespace esphome::at581x { -static const char *const TAG = "at581x"; +ESPHOME_LOG_TAG(TAG, "at581x"); bool AT581XComponent::i2c_write_reg(uint8_t addr, uint8_t data) { return this->write_register(addr, &data, 1) == esphome::i2c::NO_ERROR; diff --git a/esphome/components/atc_mithermometer/__init__.py b/esphome/components/atc_mithermometer/__init__.py index e69de29bb2..3d517583e8 100644 --- a/esphome/components/atc_mithermometer/__init__.py +++ b/esphome/components/atc_mithermometer/__init__.py @@ -0,0 +1 @@ +DOMAIN = "atc_mithermometer" diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp index 22cb2b3150..95b482c840 100644 --- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp +++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp @@ -3,7 +3,7 @@ namespace esphome::atc_mithermometer { -static const char *const TAG = "atc_mithermometer"; +ESPHOME_LOG_TAG(TAG, "atc_mithermometer"); void ATCMiThermometer::dump_config() { ESP_LOGCONFIG(TAG, "ATC MiThermometer"); 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/__init__.py b/esphome/components/atm90e26/__init__.py index ac441a9c2d..b1047e9290 100644 --- a/esphome/components/atm90e26/__init__.py +++ b/esphome/components/atm90e26/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@danieltwagner"] +DOMAIN = "atm90e26" diff --git a/esphome/components/atm90e26/atm90e26.cpp b/esphome/components/atm90e26/atm90e26.cpp index 46948be47d..8ec611357c 100644 --- a/esphome/components/atm90e26/atm90e26.cpp +++ b/esphome/components/atm90e26/atm90e26.cpp @@ -4,7 +4,7 @@ namespace esphome::atm90e26 { -static const char *const TAG = "atm90e26"; +ESPHOME_LOG_TAG(TAG, "atm90e26"); void ATM90E26Component::update() { if (this->read16_(ATM90E26_REGISTER_FUNCEN) != 0x0030) { 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/__init__.py b/esphome/components/atm90e32/__init__.py index 766807872b..d25f5c1ce9 100644 --- a/esphome/components/atm90e32/__init__.py +++ b/esphome/components/atm90e32/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@circuitsetup", "@descipher"] +DOMAIN = "atm90e32" atm90e32_ns = cg.esphome_ns.namespace("atm90e32") ATM90E32Component = atm90e32_ns.class_("ATM90E32Component", cg.Component) diff --git a/esphome/components/atm90e32/atm90e32.cpp b/esphome/components/atm90e32/atm90e32.cpp index 23701e7834..0c4f095ab5 100644 --- a/esphome/components/atm90e32/atm90e32.cpp +++ b/esphome/components/atm90e32/atm90e32.cpp @@ -7,7 +7,7 @@ namespace esphome::atm90e32 { -static const char *const TAG = "atm90e32"; +ESPHOME_LOG_TAG(TAG, "atm90e32"); static const LogString *offset_calibration_name(bool power_offsets) { return power_offsets ? LOG_STR("Power offset") : LOG_STR("Offset"); diff --git a/esphome/components/atm90e32/button/atm90e32_button.cpp b/esphome/components/atm90e32/button/atm90e32_button.cpp index e120e45364..0387149521 100644 --- a/esphome/components/atm90e32/button/atm90e32_button.cpp +++ b/esphome/components/atm90e32/button/atm90e32_button.cpp @@ -4,7 +4,7 @@ namespace esphome::atm90e32 { -static const char *const TAG = "atm90e32.button"; +ESPHOME_LOG_TAG(TAG, "atm90e32.button"); void ATM90E32GainCalibrationButton::press_action() { if (this->parent_ == nullptr) { 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 b882aaa6b7..332638e75b 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -6,7 +6,7 @@ import esphome.codegen as cg from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, - include_builtin_idf_component, + request_http_client, require_certificate_bundle, ) import esphome.config_validation as cv @@ -334,8 +334,7 @@ def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> N async def to_code(config: ConfigType) -> None: - # Re-enable ESP-IDF's HTTP client (excluded by default to save compile time) - include_builtin_idf_component("esp_http_client") + request_http_client() # HTTPS streams verify the server against the root certificate bundle require_certificate_bundle() @@ -392,7 +391,7 @@ async def to_code(config: ConfigType) -> None: # Adds a define and IDF component for legacy `audio_decoder.cpp`. if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") - add_idf_component(name="esphome/micro-flac", ref="0.2.0") + add_idf_component(name="esphome/micro-flac", ref="0.3.0") _emit_memory_pair( data.flac.buffer_memory, "CONFIG_MICRO_FLAC_PREFER_PSRAM", diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index 051395606c..13dea3f9bc 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -7,7 +7,7 @@ namespace esphome::audio { -static const char *const TAG = "audio.decoder"; +ESPHOME_LOG_TAG(TAG, "audio.decoder"); static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp index e69f33ac2d..ea3c82e637 100644 --- a/esphome/components/audio/audio_reader.cpp +++ b/esphome/components/audio/audio_reader.cpp @@ -22,7 +22,7 @@ static const size_t HTTP_STREAM_BUFFER_SIZE = 2048; static const uint8_t MAX_REDIRECTIONS = 5; -static const char *const TAG = "audio_reader"; +ESPHOME_LOG_TAG(TAG, "audio_reader"); // Some common HTTP status codes - borrowed from http_request component accessed 20241224 enum HttpStatus { @@ -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..ba6544b50b 100644 --- a/esphome/components/audio_adc/__init__.py +++ b/esphome/components/audio_adc/__init__.py @@ -1,19 +1,17 @@ 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"] +DOMAIN = "audio_adc" 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 +22,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 fbaa72014f..346f1be798 100644 --- a/esphome/components/audio_dac/__init__.py +++ b/esphome/components/audio_dac/__init__.py @@ -7,6 +7,7 @@ from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "audio_dac" IS_PLATFORM_COMPONENT = True audio_dac_ns = cg.esphome_ns.namespace("audio_dac") 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_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.cpp b/esphome/components/audio_file/media_source/audio_file_media_source.cpp index 0cda1eca9e..0aebcc176f 100644 --- a/esphome/components/audio_file/media_source/audio_file_media_source.cpp +++ b/esphome/components/audio_file/media_source/audio_file_media_source.cpp @@ -11,7 +11,7 @@ namespace esphome::audio_file { -static const char *const TAG = "audio_file_media_source"; +ESPHOME_LOG_TAG(TAG, "audio_file_media_source"); static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50; static constexpr size_t DECODER_TASK_STACK_SIZE = 5120; 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/__init__.py b/esphome/components/audio_http/__init__.py index e69de29bb2..0126fa0e8b 100644 --- a/esphome/components/audio_http/__init__.py +++ b/esphome/components/audio_http/__init__.py @@ -0,0 +1 @@ +DOMAIN = "audio_http" diff --git a/esphome/components/audio_http/audio_http_media_source.cpp b/esphome/components/audio_http/audio_http_media_source.cpp index fb8620f7d9..ce1f254310 100644 --- a/esphome/components/audio_http/audio_http_media_source.cpp +++ b/esphome/components/audio_http/audio_http_media_source.cpp @@ -11,7 +11,7 @@ namespace esphome::audio_http { -static const char *const TAG = "audio_http_media_source"; +ESPHOME_LOG_TAG(TAG, "audio_http_media_source"); // Decoder task / buffer tuning. Kept here as constants so the header stays free of magic numbers. static constexpr size_t DEFAULT_TRANSFER_BUFFER_SIZE = 8 * 1024; // Staging buffer between HTTP reader and decoder @@ -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..6c491379c8 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 +from esphome.components.const import CONF_VERIFY_SSL 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" 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/axs15231/__init__.py b/esphome/components/axs15231/__init__.py index 3246dbed24..bed826cf8b 100644 --- a/esphome/components/axs15231/__init__.py +++ b/esphome/components/axs15231/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "axs15231" axs15231_ns = cg.esphome_ns.namespace("axs15231") diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp index 3869224b91..1d6354549c 100644 --- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp +++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp @@ -5,7 +5,7 @@ namespace esphome::axs15231 { -static const char *const TAG = "ax15231.touchscreen"; +ESPHOME_LOG_TAG(TAG, "ax15231.touchscreen"); constexpr static const uint8_t AXS_READ_TOUCHPAD[11] = {0xb5, 0xab, 0xa5, 0x5a, 0x0, 0x0, 0x0, 0x8}; diff --git a/esphome/components/b_parasite/__init__.py b/esphome/components/b_parasite/__init__.py index e69de29bb2..147319ba95 100644 --- a/esphome/components/b_parasite/__init__.py +++ b/esphome/components/b_parasite/__init__.py @@ -0,0 +1 @@ +DOMAIN = "b_parasite" diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp index e0ae824b0d..8bed59b3db 100644 --- a/esphome/components/b_parasite/b_parasite.cpp +++ b/esphome/components/b_parasite/b_parasite.cpp @@ -3,7 +3,7 @@ namespace esphome::b_parasite { -static const char *const TAG = "b_parasite"; +ESPHOME_LOG_TAG(TAG, "b_parasite"); void BParasite::dump_config() { ESP_LOGCONFIG(TAG, "b_parasite"); diff --git a/esphome/components/ballu/__init__.py b/esphome/components/ballu/__init__.py index e69de29bb2..d0d8d1b3b2 100644 --- a/esphome/components/ballu/__init__.py +++ b/esphome/components/ballu/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ballu" diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp index eebc970795..e18325303c 100644 --- a/esphome/components/ballu/ballu.cpp +++ b/esphome/components/ballu/ballu.cpp @@ -3,7 +3,7 @@ namespace esphome::ballu { -static const char *const TAG = "ballu.climate"; +ESPHOME_LOG_TAG(TAG, "ballu.climate"); const uint16_t BALLU_HEADER_MARK = 9000; const uint16_t BALLU_HEADER_SPACE = 4500; @@ -146,10 +146,7 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; - // Calculate checksum and compare with signal value. - for (uint8_t i = 0; i < BALLU_STATE_LENGTH - 1; i++) - checksum += remote_state[i]; + const uint8_t checksum = sum_bytes(remote_state, BALLU_STATE_LENGTH - 1); if (checksum != remote_state[BALLU_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); diff --git a/esphome/components/bang_bang/__init__.py b/esphome/components/bang_bang/__init__.py index 71a87b6ae5..6536dc2c4f 100644 --- a/esphome/components/bang_bang/__init__.py +++ b/esphome/components/bang_bang/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "bang_bang" diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index a1104aa1b2..1546df9f10 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -3,7 +3,7 @@ namespace esphome::bang_bang { -static const char *const TAG = "bang_bang.climate"; +ESPHOME_LOG_TAG(TAG, "bang_bang.climate"); BangBangClimate::BangBangClimate() = default; diff --git a/esphome/components/bedjet/__init__.py b/esphome/components/bedjet/__init__.py index 1b967e665a..61dc73c73d 100644 --- a/esphome/components/bedjet/__init__.py +++ b/esphome/components/bedjet/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jhansche"] DEPENDENCIES = ["ble_client"] +DOMAIN = "bedjet" MULTI_CONF = True CONF_BEDJET_ID = "bedjet_id" 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/beken_spi_led_strip/__init__.py b/esphome/components/beken_spi_led_strip/__init__.py index e69de29bb2..940eabbeb6 100644 --- a/esphome/components/beken_spi_led_strip/__init__.py +++ b/esphome/components/beken_spi_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "beken_spi_led_strip" diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp index 0cf970b3cc..dcaae6d8d4 100644 --- a/esphome/components/beken_spi_led_strip/led_strip.cpp +++ b/esphome/components/beken_spi_led_strip/led_strip.cpp @@ -35,7 +35,7 @@ static const uint32_t SPI_RX_FINISH_EN = 1 << 3; namespace esphome::beken_spi_led_strip { -static const char *const TAG = "beken_spi_led_strip"; +ESPHOME_LOG_TAG(TAG, "beken_spi_led_strip"); struct SpiData { SemaphoreHandle_t dma_tx_semaphore; diff --git a/esphome/components/bh1745/__init__.py b/esphome/components/bh1745/__init__.py new file mode 100644 index 0000000000..bd63fd4ef2 --- /dev/null +++ b/esphome/components/bh1745/__init__.py @@ -0,0 +1,75 @@ +from typing import Any + +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import ( + CONF_GAIN, + CONF_GLASS_ATTENUATION_FACTOR, + CONF_ID, + CONF_INTEGRATION_TIME, +) +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bh1745" +MULTI_CONF = True + +CONF_BH1745_ID = "bh1745_id" + +bh1745_ns = cg.esphome_ns.namespace("bh1745") + +BH1745Component = bh1745_ns.class_( + "BH1745Component", cg.PollingComponent, i2c.I2CDevice +) + +AdcGain = bh1745_ns.enum("AdcGain", is_class=True) +ADC_GAINS = { + "1X": AdcGain.ADC_GAIN_1X, + "2X": AdcGain.ADC_GAIN_2X, + "16X": AdcGain.ADC_GAIN_16X, +} + +MeasurementTime = bh1745_ns.enum("MeasurementTime", is_class=True) +MEASUREMENT_TIMES = { + 160: MeasurementTime.MEASUREMENT_TIME_160MS, + 320: MeasurementTime.MEASUREMENT_TIME_320MS, + 640: MeasurementTime.MEASUREMENT_TIME_640MS, + 1280: MeasurementTime.MEASUREMENT_TIME_1280MS, + 2560: MeasurementTime.MEASUREMENT_TIME_2560MS, + 5120: MeasurementTime.MEASUREMENT_TIME_5120MS, +} + + +def validate_measurement_time(value: Any) -> Any: + value = cv.positive_time_period_milliseconds(value).total_milliseconds + return cv.enum(MEASUREMENT_TIMES, int=True)(value) + + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BH1745Component), + cv.Optional(CONF_GAIN, default="1X"): cv.enum(ADC_GAINS, upper=True), + cv.Optional( + CONF_INTEGRATION_TIME, default="160ms" + ): validate_measurement_time, + cv.Optional(CONF_GLASS_ATTENUATION_FACTOR, default=1.0): cv.float_range( + min=1.0 + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x38)) +) + + +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_adc_gain(config[CONF_GAIN])) + cg.add(var.set_measurement_time(config[CONF_INTEGRATION_TIME])) + cg.add(var.set_glass_attenuation_factor(config[CONF_GLASS_ATTENUATION_FACTOR])) diff --git a/esphome/components/bh1745/bh1745.cpp b/esphome/components/bh1745/bh1745.cpp new file mode 100644 index 0000000000..24744c3330 --- /dev/null +++ b/esphome/components/bh1745/bh1745.cpp @@ -0,0 +1,182 @@ +#include "bh1745.h" +#include +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::bh1745 { + +ESPHOME_LOG_TAG(TAG, "bh1745"); + +static constexpr uint8_t REG_SYSTEM_CONTROL = 0x40; +static constexpr uint8_t REG_MODE_CONTROL1 = 0x41; +static constexpr uint8_t REG_MODE_CONTROL2 = 0x42; +static constexpr uint8_t REG_MODE_CONTROL3 = 0x44; +static constexpr uint8_t REG_RED_DATA_LSB = 0x50; +static constexpr uint8_t REG_INTERRUPT = 0x60; +static constexpr uint8_t REG_TH_LSB = 0x62; +static constexpr uint8_t REG_MANUFACTURER_ID = 0x92; + +static constexpr uint8_t MANUFACTURER_ID = 0xE0; +static constexpr uint8_t PART_ID = 0b001011; +static constexpr uint8_t SYSTEM_CONTROL_PART_ID_MASK = 0x3F; +static constexpr uint8_t SYSTEM_CONTROL_SW_RESET = 0x80; +static constexpr uint8_t MODE_CONTROL2_RGBC_EN = 0x10; +static constexpr uint8_t MODE_CONTROL2_VALID = 0x80; +static constexpr uint8_t MODE_CONTROL3_VALUE = 0x02; // the datasheet says always write 0x02 +static constexpr uint8_t INTERRUPT_ENABLE = 0x01; + +static constexpr uint8_t BH1745_CHANNELS = 4; +static constexpr uint32_t RESET_DELAY_MS = 100; +static constexpr uint32_t BASE_MEAS_TIME_MS = 160; + +static uint32_t get_measurement_time_ms(MeasurementTime time) { + return BASE_MEAS_TIME_MS << static_cast(time); +} + +static uint8_t get_adc_gain(AdcGain gain) { + return gain == AdcGain::ADC_GAIN_16X ? 16 : 1 + static_cast(gain); +} + +static float calculate_lux(uint16_t red, uint16_t green, bool low_clear_ratio, float scale) { + if (green == 0) + return 0.0f; + float lx = low_clear_ratio ? 0.202f * red + 0.766f * green : 0.159f * red + 0.646f * green; + return lx * scale; +} + +static float calculate_cct(uint16_t red, uint16_t green, uint16_t blue, bool low_clear_ratio) { + if (green == 0) + return 0.0f; + float all = static_cast(static_cast(red) + green + blue); + float r_ratio = red / all; + float b_ratio = blue / all; + float ct; + if (low_clear_ratio) { + float b_eff = std::fmin(b_ratio * 3.13f, 1.0f); + ct = ((1.0f - b_eff) * 12746.0f * std::exp(-2.911f * r_ratio)) + (b_eff * 1637.0f * std::exp(4.865f * b_ratio)); + } else { + float b_eff = std::fmin(b_ratio * 10.67f, 1.0f); + ct = ((1.0f - b_eff) * 16234.0f * std::exp(-2.781f * r_ratio)) + (b_eff * 1882.0f * std::exp(4.448f * b_ratio)); + } + return clamp_at_most(ct, 10000.0f); +} + +void BH1745Component::setup() { + uint8_t manufacturer_id; + if (!this->read_byte(REG_MANUFACTURER_ID, &manufacturer_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if (manufacturer_id != MANUFACTURER_ID) { + this->mark_failed(LOG_STR("Wrong manufacturer ID")); + return; + } + uint8_t system_control; + if (!this->read_byte(REG_SYSTEM_CONTROL, &system_control) || + (system_control & SYSTEM_CONTROL_PART_ID_MASK) != PART_ID) { + this->mark_failed(LOG_STR("Wrong part ID")); + return; + } + if (!this->write_byte(REG_SYSTEM_CONTROL, SYSTEM_CONTROL_SW_RESET)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Light lost behind glass is scaled back up, as the other light sensors do + this->lux_scale_ = 160.0f * this->glass_attenuation_factor_ / + (get_adc_gain(this->adc_gain_) * get_measurement_time_ms(this->measurement_time_)); + + this->set_timeout(RESET_DELAY_MS, [this]() { + if (this->interrupt_output_) { + // High threshold 0 and low threshold 0xFFFF (low byte first) make the interrupt condition always + // true, so the INT pin simply follows the enable bit, which drives the breakout's LEDs. + static constexpr uint8_t THRESHOLDS[4] = {0x00, 0x00, 0xFF, 0xFF}; + if (!this->write_bytes(REG_TH_LSB, THRESHOLDS, sizeof(THRESHOLDS))) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + } + // RGBC_EN makes the chip measure continuously; update() reads the latest result. + if (!this->write_byte(REG_MODE_CONTROL1, static_cast(this->measurement_time_)) || + !this->write_byte(REG_MODE_CONTROL2, static_cast(this->adc_gain_) | MODE_CONTROL2_RGBC_EN) || + !this->write_byte(REG_MODE_CONTROL3, MODE_CONTROL3_VALUE)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->busy_ = false; + }); +} + +void BH1745Component::dump_config() { + ESP_LOGCONFIG(TAG, + "BH1745:\n" + " Gain: %ux\n" + " Integration time: %" PRIu32 " ms\n" + " Glass attenuation factor: %.2f", + get_adc_gain(this->adc_gain_), get_measurement_time_ms(this->measurement_time_), + this->glass_attenuation_factor_); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + + LOG_SENSOR(" ", "Red Counts", this->red_counts_sensor_); + LOG_SENSOR(" ", "Green Counts", this->green_counts_sensor_); + LOG_SENSOR(" ", "Blue Counts", this->blue_counts_sensor_); + LOG_SENSOR(" ", "Clear Counts", this->clear_counts_sensor_); + LOG_SENSOR(" ", "Illuminance", this->illuminance_sensor_); + LOG_SENSOR(" ", "Color Temperature", this->color_temperature_sensor_); +} + +void BH1745Component::update() { + if (this->busy_) + return; + + // Reading MODE_CONTROL2 clears VALID, so read it only once per poll. + uint8_t mode_control2; + if (!this->read_byte(REG_MODE_CONTROL2, &mode_control2)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((mode_control2 & MODE_CONTROL2_VALID) == 0) + return; + + uint8_t buffer[BH1745_CHANNELS * 2]; + if (!this->read_bytes(REG_RED_DATA_LSB, buffer, sizeof(buffer))) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + + // Each channel is sent low byte first. + const uint16_t red = encode_uint16(buffer[1], buffer[0]); + const uint16_t green = encode_uint16(buffer[3], buffer[2]); + const uint16_t blue = encode_uint16(buffer[5], buffer[4]); + const uint16_t clear = encode_uint16(buffer[7], buffer[6]); + ESP_LOGV(TAG, "Red: %u, Green: %u, Blue: %u, Clear: %u", red, green, blue, clear); + + if (this->red_counts_sensor_ != nullptr) + this->red_counts_sensor_->publish_state(red); + if (this->green_counts_sensor_ != nullptr) + this->green_counts_sensor_->publish_state(green); + if (this->blue_counts_sensor_ != nullptr) + this->blue_counts_sensor_->publish_state(blue); + if (this->clear_counts_sensor_ != nullptr) + this->clear_counts_sensor_->publish_state(clear); + + // The datasheet picks its coefficients by whether clear / green < 0.160 (= 4 / 25). + const bool low_clear_ratio = static_cast(clear) * 25 < static_cast(green) * 4; + if (this->illuminance_sensor_ != nullptr) + this->illuminance_sensor_->publish_state(calculate_lux(red, green, low_clear_ratio, this->lux_scale_)); + if (this->color_temperature_sensor_ != nullptr) + this->color_temperature_sensor_->publish_state(calculate_cct(red, green, blue, low_clear_ratio)); +} + +void BH1745Component::set_interrupt_state(bool on) { + if (this->is_failed()) + return; + if (!this->write_byte(REG_INTERRUPT, on ? INTERRUPT_ENABLE : 0x00)) { + this->status_set_warning(); + } +} + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/bh1745.h b/esphome/components/bh1745/bh1745.h new file mode 100644 index 0000000000..6463aab38d --- /dev/null +++ b/esphome/components/bh1745/bh1745.h @@ -0,0 +1,54 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bh1745 { + +enum class MeasurementTime : uint8_t { + MEASUREMENT_TIME_160MS = 0b000, + MEASUREMENT_TIME_320MS = 0b001, + MEASUREMENT_TIME_640MS = 0b010, + MEASUREMENT_TIME_1280MS = 0b011, + MEASUREMENT_TIME_2560MS = 0b100, + MEASUREMENT_TIME_5120MS = 0b101, +}; + +enum class AdcGain : uint8_t { + ADC_GAIN_1X = 0, + ADC_GAIN_2X = 1, + ADC_GAIN_16X = 2, +}; + +class BH1745Component : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(red_counts) + SUB_SENSOR(green_counts) + SUB_SENSOR(blue_counts) + SUB_SENSOR(clear_counts) + SUB_SENSOR(illuminance) + SUB_SENSOR(color_temperature) + + public: + void setup() override; + void dump_config() override; + void update() override; + + void set_measurement_time(MeasurementTime measurement_time) { this->measurement_time_ = measurement_time; } + void set_adc_gain(AdcGain adc_gain) { this->adc_gain_ = adc_gain; } + void set_glass_attenuation_factor(float factor) { this->glass_attenuation_factor_ = factor; } + + /// Called when an output platform uses the INT pin, so setup() pins the interrupt thresholds. + void enable_interrupt_output() { this->interrupt_output_ = true; } + void set_interrupt_state(bool on); + + protected: + float glass_attenuation_factor_{1.0f}; + float lux_scale_{1.0f}; // 160 / (gain * integration time * glass attenuation), set in setup() + MeasurementTime measurement_time_{MeasurementTime::MEASUREMENT_TIME_160MS}; + AdcGain adc_gain_{AdcGain::ADC_GAIN_1X}; + bool busy_{true}; // cleared once the chip is configured after the reset + bool interrupt_output_{false}; +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/output/__init__.py b/esphome/components/bh1745/output/__init__.py new file mode 100644 index 0000000000..6c5da0396b --- /dev/null +++ b/esphome/components/bh1745/output/__init__.py @@ -0,0 +1,28 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import CONF_BH1745_ID, BH1745Component, bh1745_ns + +DEPENDENCIES = ["bh1745"] + +BH1745InterruptPinOutput = bh1745_ns.class_( + "BH1745InterruptPinOutput", output.BinaryOutput +) + +CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BH1745InterruptPinOutput), + cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await output.register_output(var, config) + hub = await cg.get_variable(config[CONF_BH1745_ID]) + await cg.register_parented(var, hub) + cg.add(hub.enable_interrupt_output()) diff --git a/esphome/components/bh1745/output/bh1745_output.h b/esphome/components/bh1745/output/bh1745_output.h new file mode 100644 index 0000000000..c6f30304bd --- /dev/null +++ b/esphome/components/bh1745/output/bh1745_output.h @@ -0,0 +1,16 @@ +#pragma once + +#include "esphome/components/output/binary_output.h" +#include "esphome/core/helpers.h" + +#include "../bh1745.h" + +namespace esphome::bh1745 { + +/// Drives the BH1745 INT pin, which switches the LEDs on breakout boards such as the Pimoroni one. +class BH1745InterruptPinOutput : public output::BinaryOutput, public Parented { + protected: + void write_state(bool state) override { this->parent_->set_interrupt_state(state); } +}; + +} // namespace esphome::bh1745 diff --git a/esphome/components/bh1745/sensor.py b/esphome/components/bh1745/sensor.py new file mode 100644 index 0000000000..6c90446625 --- /dev/null +++ b/esphome/components/bh1745/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, + UNIT_COUNTS, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_COLOR_TEMPERATURE, + CONF_ILLUMINANCE, + DEVICE_CLASS_ILLUMINANCE, + ICON_LIGHTBULB, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_KELVIN, + UNIT_LUX, +) +from esphome.types import ConfigType + +from . import CONF_BH1745_ID, BH1745Component + +DEPENDENCIES = ["bh1745"] + +color_channel_schema = sensor.sensor_schema( + unit_of_measurement=UNIT_COUNTS, + icon=ICON_LIGHTBULB, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_BH1745_ID): cv.use_id(BH1745Component), + cv.Optional(CONF_RED_CHANNEL): color_channel_schema, + cv.Optional(CONF_GREEN_CHANNEL): color_channel_schema, + cv.Optional(CONF_BLUE_CHANNEL): color_channel_schema, + cv.Optional(CONF_CLEAR_CHANNEL): color_channel_schema, + cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema( + unit_of_measurement=UNIT_LUX, + icon=ICON_LIGHTBULB, + accuracy_decimals=1, + device_class=DEVICE_CLASS_ILLUMINANCE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COLOR_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_KELVIN, + icon=ICON_THERMOMETER, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_BH1745_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_RED_CHANNEL, hub.set_red_counts_sensor) + await sensors(CONF_GREEN_CHANNEL, hub.set_green_counts_sensor) + await sensors(CONF_BLUE_CHANNEL, hub.set_blue_counts_sensor) + await sensors(CONF_CLEAR_CHANNEL, hub.set_clear_counts_sensor) + await sensors(CONF_ILLUMINANCE, hub.set_illuminance_sensor) + await sensors(CONF_COLOR_TEMPERATURE, hub.set_color_temperature_sensor) diff --git a/esphome/components/bh1750/__init__.py b/esphome/components/bh1750/__init__.py index e69de29bb2..67b3f5ba5a 100644 --- a/esphome/components/bh1750/__init__.py +++ b/esphome/components/bh1750/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bh1750" diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp index ab952895a8..2d9709f422 100644 --- a/esphome/components/bh1750/bh1750.cpp +++ b/esphome/components/bh1750/bh1750.cpp @@ -4,7 +4,7 @@ namespace esphome::bh1750 { -static const char *const TAG = "bh1750.sensor"; +ESPHOME_LOG_TAG(TAG, "bh1750.sensor"); static const uint8_t BH1750_COMMAND_POWER_ON = 0b00000001; static const uint8_t BH1750_COMMAND_MT_REG_HI = 0b01000000; // last 3 bits diff --git a/esphome/components/bh1900nux/__init__.py b/esphome/components/bh1900nux/__init__.py index e69de29bb2..b5b6c5eac9 100644 --- a/esphome/components/bh1900nux/__init__.py +++ b/esphome/components/bh1900nux/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bh1900nux" diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp index 4f746a17d4..6b7f1a3586 100644 --- a/esphome/components/bh1900nux/bh1900nux.cpp +++ b/esphome/components/bh1900nux/bh1900nux.cpp @@ -3,7 +3,7 @@ namespace esphome::bh1900nux { -static const char *const TAG = "bh1900nux.sensor"; +ESPHOME_LOG_TAG(TAG, "bh1900nux.sensor"); // I2C Registers static const uint8_t TEMPERATURE_REG = 0x00; diff --git a/esphome/components/binary/__init__.py b/esphome/components/binary/__init__.py index 3f6a45d28a..6244d89b8d 100644 --- a/esphome/components/binary/__init__.py +++ b/esphome/components/binary/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "binary" + binary_ns = cg.esphome_ns.namespace("binary") diff --git a/esphome/components/binary/fan/binary_fan.cpp b/esphome/components/binary/fan/binary_fan.cpp index 118d87c09d..4cc0b145a2 100644 --- a/esphome/components/binary/fan/binary_fan.cpp +++ b/esphome/components/binary/fan/binary_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::binary { -static const char *const TAG = "binary.fan"; +ESPHOME_LOG_TAG(TAG, "binary.fan"); void BinaryFan::setup() { auto restore = this->restore_state_(); diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py index da0276c037..1384f8b008 100644 --- a/esphome/components/binary_sensor/__init__.py +++ b/esphome/components/binary_sensor/__init__.py @@ -61,17 +61,23 @@ 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"] + +DEFAULT_INVALID_COOLDOWN_MS = ( + 1000 # Keep in sync with invalid_cooldown_ in automation.h +) DEVICE_CLASSES = [ DEVICE_CLASS_BATTERY, DEVICE_CLASS_BATTERY_CHARGING, @@ -104,6 +110,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_VIBRATION, DEVICE_CLASS_WINDOW, ] +DOMAIN = "binary_sensor" IS_PLATFORM_COMPONENT = True @@ -128,15 +135,11 @@ ClickTrigger = binary_sensor_ns.class_("ClickTrigger", automation.Trigger.templa DoubleClickTrigger = binary_sensor_ns.class_( "DoubleClickTrigger", automation.Trigger.template() ) -MultiClickTriggerBase = binary_sensor_ns.class_( - "MultiClickTriggerBase", automation.Trigger.template(), cg.Component +MultiClickTrigger = binary_sensor_ns.class_( + "MultiClickTrigger", automation.Trigger.template(), cg.Component ) -MultiClickTrigger = binary_sensor_ns.class_("MultiClickTrigger", MultiClickTriggerBase) MultiClickTriggerEvent = binary_sensor_ns.struct("MultiClickTriggerEvent") -BinarySensorInvalidateAction = binary_sensor_ns.class_( - "BinarySensorInvalidateAction", automation.Action -) # Filters Filter = binary_sensor_ns.class_("Filter") @@ -492,7 +495,8 @@ _BINARY_SENSOR_SCHEMA = ( cv.Length(min=1, max=255), ), cv.Optional( - CONF_INVALID_COOLDOWN, default="1s" + CONF_INVALID_COOLDOWN, + default=f"{DEFAULT_INVALID_COOLDOWN_MS}ms", ): cv.positive_time_period_milliseconds, } ), @@ -586,11 +590,14 @@ async def _build_binary_sensor_automations(var, config): ) for tim in conf[CONF_TIMING] ] - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], cg.TemplateArguments(len(timings)), var, timings + table = cg.shared_progmem_array( + "multi_click_timing", MultiClickTriggerEvent, cg.ArrayInitializer(*timings) ) - if CONF_INVALID_COOLDOWN in conf: - cg.add(trigger.set_invalid_cooldown(conf[CONF_INVALID_COOLDOWN])) + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, table, len(timings)) + if ( + cooldown := conf[CONF_INVALID_COOLDOWN] + ).total_milliseconds != DEFAULT_INVALID_COOLDOWN_MS: + cg.add(trigger.set_invalid_cooldown(cooldown)) await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) @@ -607,7 +614,8 @@ async def setup_binary_sensor_core_(var, config): if filters_config := config.get(CONF_FILTERS): cg.add_define("USE_BINARY_SENSOR_FILTER") filters = await cg.build_registry_list(FILTER_REGISTRY, filters_config) - cg.add(var.add_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_binary_sensor_automations, var, config) @@ -635,6 +643,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), @@ -655,20 +670,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.cpp b/esphome/components/binary_sensor/automation.cpp index 65c7dbbdb6..46f7d9488c 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -8,7 +8,7 @@ namespace esphome::binary_sensor { #ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER -static const char *const TAG = "binary_sensor.automation"; +ESPHOME_LOG_TAG(TAG, "binary_sensor.automation"); // MultiClickTrigger timeout IDs. // MultiClickTrigger is its own Component instance, so the scheduler scopes @@ -18,7 +18,7 @@ constexpr uint32_t MULTICLICK_COOLDOWN_ID = 1; constexpr uint32_t MULTICLICK_IS_VALID_ID = 2; constexpr uint32_t MULTICLICK_IS_NOT_VALID_ID = 3; -void MultiClickTriggerBase::on_state_(bool state) { +void MultiClickTrigger::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { return; @@ -30,7 +30,7 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - if (!this->at_index_.has_value()) { + if (this->at_index_ == 0) { // Start matching MultiClickTriggerEvent evt = this->timing_[0]; if (evt.state == state) { @@ -55,8 +55,7 @@ void MultiClickTriggerBase::on_state_(bool state) { return; } - // at_index_ has a value here (the !has_value() branch above returns). - size_t at_index = *this->at_index_; + uint8_t at_index = this->at_index_; if (at_index == this->timing_count_) { this->trigger_(); return; @@ -65,15 +64,15 @@ void MultiClickTriggerBase::on_state_(bool state) { MultiClickTriggerEvent evt = this->timing_[at_index]; if (evt.max_length != 4294967294UL) { - ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT + ESP_LOGV(TAG, "A i=%u min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); this->schedule_is_valid_(evt.min_length); this->schedule_is_not_valid_(evt.max_length); } else if (at_index + 1 != this->timing_count_) { - ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT + ESP_LOGV(TAG, "B i=%u min=%" PRIu32, at_index, evt.min_length); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->schedule_is_valid_(evt.min_length); } else { - ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT + ESP_LOGV(TAG, "C i=%u min=%" PRIu32, at_index, evt.min_length); this->is_valid_ = false; this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); }); @@ -81,19 +80,19 @@ void MultiClickTriggerBase::on_state_(bool state) { this->at_index_ = at_index + 1; } -void MultiClickTriggerBase::schedule_cooldown_() { +void MultiClickTrigger::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; this->set_timeout(MULTICLICK_COOLDOWN_ID, this->invalid_cooldown_, [this]() { ESP_LOGV(TAG, "Multi Click: Cooldown ended, matching is now enabled again."); this->is_in_cooldown_ = false; }); - this->at_index_.reset(); + this->at_index_ = 0; this->cancel_timeout(MULTICLICK_TRIGGER_ID); this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); } -void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { +void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { this->is_valid_ = true; return; @@ -105,7 +104,7 @@ void MultiClickTriggerBase::schedule_is_valid_(uint32_t min_length) { this->is_valid_ = true; }); } -void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { +void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout(MULTICLICK_IS_NOT_VALID_ID, max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? LOG_STR_LITERAL("RELEASE") : LOG_STR_LITERAL("PRESS")); @@ -113,14 +112,14 @@ void MultiClickTriggerBase::schedule_is_not_valid_(uint32_t max_length) { this->schedule_cooldown_(); }); } -void MultiClickTriggerBase::cancel() { +void MultiClickTrigger::cancel() { ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled."); this->is_valid_ = false; this->schedule_cooldown_(); } -void MultiClickTriggerBase::trigger_() { +void MultiClickTrigger::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); - this->at_index_.reset(); + this->at_index_ = 0; this->cancel_timeout(MULTICLICK_TRIGGER_ID); this->cancel_timeout(MULTICLICK_IS_VALID_ID); this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID); diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index 15a1a2c8a0..7426cc5614 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include #include "esphome/core/component.h" @@ -13,30 +13,14 @@ namespace esphome::binary_sensor { struct MultiClickTriggerEvent { - bool state; + uint32_t state; // Not bool: all-word fields allow aligned loads from PROGMEM on ESP8266 uint32_t min_length; 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(); - }); - } -}; +// Read straight from PROGMEM; a byte-sized field would fault on ESP8266. +static_assert(std::is_same_v && + sizeof(MultiClickTriggerEvent) == 3 * sizeof(uint32_t), + "MultiClickTriggerEvent fields must all be uint32_t"); bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length); @@ -90,10 +74,11 @@ class DoubleClickTrigger final : public Trigger<> { uint32_t max_length_; /// Maximum length of click. 0 means no maximum. }; -/// Non-template base for MultiClickTrigger (keeps large method bodies out of the header). -class MultiClickTriggerBase : public Trigger<>, public Component { +class MultiClickTrigger final : public Trigger<>, public Component { public: - explicit MultiClickTriggerBase(BinarySensor *parent) : parent_(parent) {} + /// `timing` is a codegen PROGMEM table shared by triggers with the same timing. + MultiClickTrigger(BinarySensor *parent, const MultiClickTriggerEvent *timing, uint8_t timing_count) + : parent_(parent), timing_(timing), timing_count_(timing_count) {} void setup() override { this->last_state_ = this->parent_->get_state_default(false); @@ -105,8 +90,8 @@ class MultiClickTriggerBase : public Trigger<>, public Component { void set_invalid_cooldown(uint32_t invalid_cooldown) { this->invalid_cooldown_ = invalid_cooldown; } void cancel(); - MultiClickTriggerBase(const MultiClickTriggerBase &) = delete; - MultiClickTriggerBase &operator=(const MultiClickTriggerBase &) = delete; + MultiClickTrigger(const MultiClickTrigger &) = delete; + MultiClickTrigger &operator=(const MultiClickTrigger &) = delete; protected: void on_state_(bool state); @@ -116,53 +101,13 @@ class MultiClickTriggerBase : public Trigger<>, public Component { void trigger_(); BinarySensor *parent_; - const MultiClickTriggerEvent *timing_{nullptr}; - uint32_t invalid_cooldown_{1000}; - optional at_index_{}; - uint8_t timing_count_{0}; + const MultiClickTriggerEvent *timing_; + uint32_t invalid_cooldown_{1000}; // Must match DEFAULT_INVALID_COOLDOWN_MS in __init__.py + uint8_t timing_count_; + uint8_t at_index_{0}; // 0: not matching, otherwise the index of the next timing event bool last_state_{false}; bool is_in_cooldown_{false}; bool is_valid_{false}; }; -/// Template wrapper that provides inline std::array storage for timing events. -/// N is set by code generation to match the exact number of timing events configured in YAML. -template class MultiClickTrigger final : public MultiClickTriggerBase { - public: - MultiClickTrigger(BinarySensor *parent, std::initializer_list timing) - : MultiClickTriggerBase(parent) { - init_array_from(this->timing_storage_, timing); - this->timing_ = this->timing_storage_.data(); - this->timing_count_ = N; - } - - protected: - 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 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.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 7596975a68..43915c4e17 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::binary_sensor { -static const char *const TAG = "binary_sensor"; +ESPHOME_LOG_TAG(TAG, "binary_sensor"); // Function implementation of LOG_BINARY_SENSOR macro to reduce code size void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj) { diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 8b882212c8..32b25f7211 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -8,7 +8,7 @@ namespace esphome::binary_sensor { -static const char *const TAG = "sensor.filter"; +ESPHOME_LOG_TAG(TAG, "sensor.filter"); void Filter::output(bool value) { if (this->next_ == nullptr) { diff --git a/esphome/components/binary_sensor_map/__init__.py b/esphome/components/binary_sensor_map/__init__.py index e69de29bb2..3ac51b590e 100644 --- a/esphome/components/binary_sensor_map/__init__.py +++ b/esphome/components/binary_sensor_map/__init__.py @@ -0,0 +1 @@ +DOMAIN = "binary_sensor_map" diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp index 3185f15697..4f00a85c8d 100644 --- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp +++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp @@ -3,7 +3,7 @@ namespace esphome::binary_sensor_map { -static const char *const TAG = "binary_sensor_map"; +ESPHOME_LOG_TAG(TAG, "binary_sensor_map"); void BinarySensorMap::dump_config() { LOG_SENSOR(" ", "binary_sensor_map", this); } diff --git a/esphome/components/bk72xx/__init__.py b/esphome/components/bk72xx/__init__.py index e64237c95a..1db8f5d54f 100644 --- a/esphome/components/bk72xx/__init__.py +++ b/esphome/components/bk72xx/__init__.py @@ -35,6 +35,7 @@ from .boards import BK72XX_BOARD_PINS, BK72XX_BOARDS CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["libretiny"] +DOMAIN = "bk72xx" IS_TARGET_PLATFORM = True COMPONENT_DATA = LibreTinyComponent( diff --git a/esphome/components/bk72xx_ble/__init__.py b/esphome/components/bk72xx_ble/__init__.py index 38cba56c62..44c66919bb 100644 --- a/esphome/components/bk72xx_ble/__init__.py +++ b/esphome/components/bk72xx_ble/__init__.py @@ -38,6 +38,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["bk72xx"] CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "bk72xx_ble" _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/bk72xx_ble/bdk_scan.cpp b/esphome/components/bk72xx_ble/bdk_scan.cpp index 3192fc79d7..05b94e17f9 100644 --- a/esphome/components/bk72xx_ble/bdk_scan.cpp +++ b/esphome/components/bk72xx_ble/bdk_scan.cpp @@ -29,7 +29,7 @@ extern "C" { namespace esphome::bk72xx_ble { -static const char *const TAG = "bk72xx_ble"; +ESPHOME_LOG_TAG(TAG, "bk72xx_ble"); // Pin the SDK surface this file depends on: a beken-bdk bump that moves these // must fail the build, not corrupt the kernel message. diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 7a4efff455..49167c3000 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -81,7 +81,7 @@ void ble_entry(void); namespace esphome::bk72xx_ble { -static const char *const TAG = "bk72xx_ble"; +ESPHOME_LOG_TAG(TAG, "bk72xx_ble"); static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release diff --git a/esphome/components/bk72xx_ble_tracker/__init__.py b/esphome/components/bk72xx_ble_tracker/__init__.py index 55013d4087..abef3c7752 100644 --- a/esphome/components/bk72xx_ble_tracker/__init__.py +++ b/esphome/components/bk72xx_ble_tracker/__init__.py @@ -44,6 +44,7 @@ CONF_BK72XX_BLE_ID = "bk72xx_ble_id" DEPENDENCIES = ["bk72xx"] AUTO_LOAD = ["ble_device_base", "bk72xx_ble"] CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "bk72xx_ble_tracker" ble_device_base.register_hub_provider("bk72xx_ble_tracker") diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index 0939e1259f..7e7f04135e 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -16,7 +16,7 @@ namespace esphome::bk72xx_ble_tracker { -static const char *const TAG = "bk72xx_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "bk72xx_ble_tracker"); // Minimum interval between scan (re)start attempts, so a failing controller start // cannot be retried every main-loop iteration (single-core CPU starvation). The diff --git a/esphome/components/bl0906/__init__.py b/esphome/components/bl0906/__init__.py index b077792604..b840c0ee99 100644 --- a/esphome/components/bl0906/__init__.py +++ b/esphome/components/bl0906/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@athom-tech", "@tarontop", "@jesserockz"] +DOMAIN = "bl0906" diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp index 9a27cffd04..38edb6ed91 100644 --- a/esphome/components/bl0906/bl0906.cpp +++ b/esphome/components/bl0906/bl0906.cpp @@ -5,7 +5,7 @@ namespace esphome::bl0906 { -static const char *const TAG = "bl0906"; +ESPHOME_LOG_TAG(TAG, "bl0906"); constexpr uint32_t to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } 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/__init__.py b/esphome/components/bl0939/__init__.py index 9bd4598dd2..a2c2dd4c7b 100644 --- a/esphome/components/bl0939/__init__.py +++ b/esphome/components/bl0939/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ziceva"] +DOMAIN = "bl0939" diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp index 9ae3360e3a..e8dc09abba 100644 --- a/esphome/components/bl0939/bl0939.cpp +++ b/esphome/components/bl0939/bl0939.cpp @@ -4,7 +4,7 @@ namespace esphome::bl0939 { -static const char *const TAG = "bl0939"; +ESPHOME_LOG_TAG(TAG, "bl0939"); // https://www.belling.com.cn/media/file_object/bel_product/BL0939/datasheet/BL0939_V1.2_cn.pdf // (unfortunately chinese, but the protocol can be understood with some translation tool) @@ -51,12 +51,8 @@ void BL0939::loop() { } bool BL0939::validate_checksum(const DataPacket *data) { - uint8_t checksum = BL0939_READ_COMMAND; // Whole package but checksum - for (uint32_t i = 0; i < sizeof(data->raw) - 1; i++) { - checksum += data->raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = sum_bytes(data->raw, sizeof(data->raw) - 1, BL0939_READ_COMMAND) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0939 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } @@ -120,13 +116,24 @@ void BL0939::received_package_(const DataPacket *data) const { energy_sensor_sum_->publish_state(total_energy_consumption); } + if (this->internal_temperature_sensor_ != nullptr) { + const uint16_t raw_temperature = ((uint16_t(data->tps1.h) << 8) | data->tps1.l) & 0x3FF; + // Datasheet temperature conversion: Tx = (170 / 448) * (TB / 2 - 32) - 45. + const float temperature = (170.0f / 448.0f) * (raw_temperature / 2.0f - 32.0f) - 45.0f; + this->internal_temperature_sensor_->publish_state(temperature); + } + ESP_LOGV(TAG, "BL0939: U %fV, I1 %fA, I2 %fA, P1 %fW, P2 %fW, CntA %" PRId32 ", CntB %" PRId32 ", ∫P1 %fkWh, ∫P2 %fkWh", v_rms, ia_rms, ib_rms, a_watt, b_watt, cfa_cnt, cfb_cnt, a_energy_consumption, b_energy_consumption); } void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexity) - ESP_LOGCONFIG(TAG, "BL0939:"); + ESP_LOGCONFIG(TAG, + "BL0939:\n" + " Mode: %s", + this->mode_ == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER ? LOG_STR_LITERAL("current_transformer") + : LOG_STR_LITERAL("direct")); LOG_SENSOR("", "Voltage", this->voltage_sensor_); LOG_SENSOR("", "Current 1", this->current_sensor_1_); LOG_SENSOR("", "Current 2", this->current_sensor_2_); @@ -135,6 +142,7 @@ void BL0939::dump_config() { // NOLINT(readability-function-cognitive-complexit LOG_SENSOR("", "Energy 1", this->energy_sensor_1_); LOG_SENSOR("", "Energy 2", this->energy_sensor_2_); LOG_SENSOR("", "Energy sum", this->energy_sensor_sum_); + LOG_SENSOR("", "Internal Temperature", this->internal_temperature_sensor_); } uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m << 8 | input.l; } diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h index 333bca3715..257d9459b5 100644 --- a/esphome/components/bl0939/bl0939.h +++ b/esphome/components/bl0939/bl0939.h @@ -8,13 +8,32 @@ namespace esphome::bl0939 { // https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf // (unfortunately chinese, but the formulas can be easily understood) +// The datasheet has one set of formulas for direct mode (current shunt) and one for current transformer mode. + +// Direct mode. // Sonoff Dual R3 V2 has the exact same resistor values for the current shunts (RL=1miliOhm) // and for the voltage divider (R1=0.51kOhm, R2=5*390kOhm) // as in the manufacturer's reference circuit, so the same formulas were used here (Vref=1.218V) -static const float BL0939_IREF = 324004 * 1 / 1.218; -static const float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51)); -static const float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51)); -static const float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_IREF = 324004 * 1 / 1.218; +static constexpr float BL0939_UREF = 79931 * 0.51 * 1000 / (1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_PREF = 4046 * 1 * 0.51 * 1000 / (1.218 * 1.218 * (5 * 390 + 0.51)); +static constexpr float BL0939_EREF = 3.6e6 * 4046 * 1 * 0.51 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * (5 * 390 + 0.51)); + +// Current transformer mode. +// Values from the Seeed Studio XIAO 2-Channel Wi-Fi AC Energy Meter V1.0 schematic (Vref=1.218V). +// Current: 100A:50mA CT (2000:1) into a 0.6Ohm burden (2 * 0.3Ohm), so 0.3mV per primary amp. +// That is the same as a 0.3mOhm shunt in the direct mode formula. +// Voltage: ZMPT107-1 2mA:2mA voltage transformer with R2=5*22kOhm on the primary and R1=24.9Ohm on +// the secondary. The transformer isolates R1 from R2, so the divider is R1/R2 instead of R1/(R1+R2). +static constexpr float BL0939_IREF_CT = 324004 * 0.3 / 1.218; +static constexpr float BL0939_UREF_CT = 79931 * 24.9 * 1000 / (1.218 * 5 * 22000); +static constexpr float BL0939_PREF_CT = 4046 * 0.3 * 24.9 * 1000 / (1.218 * 1.218 * 5 * 22000); +static constexpr float BL0939_EREF_CT = 3.6e6 * 4046 * 0.3 * 24.9 * 1000 / (1638.4 * 256 * 1.218 * 1.218 * 5 * 22000); + +enum class BL0939Mode : uint8_t { + BL0939_MODE_DIRECT = 0, + BL0939_MODE_CURRENT_TRANSFORMER = 1, +}; struct ube24_t { // NOLINT(readability-identifier-naming,altera-struct-pack-align) uint8_t l; @@ -66,6 +85,16 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { void set_energy_sensor_1(sensor::Sensor *energy_sensor_1) { energy_sensor_1_ = energy_sensor_1; } void set_energy_sensor_2(sensor::Sensor *energy_sensor_2) { energy_sensor_2_ = energy_sensor_2; } void set_energy_sensor_sum(sensor::Sensor *energy_sensor_sum) { energy_sensor_sum_ = energy_sensor_sum; } + void set_internal_temperature_sensor(sensor::Sensor *sensor) { this->internal_temperature_sensor_ = sensor; } + // Inline so a constant mode from codegen folds down to plain stores. + void set_mode(BL0939Mode mode) { + this->mode_ = mode; + const bool current_transformer = mode == BL0939Mode::BL0939_MODE_CURRENT_TRANSFORMER; + this->power_reference_ = current_transformer ? BL0939_PREF_CT : BL0939_PREF; + this->voltage_reference_ = current_transformer ? BL0939_UREF_CT : BL0939_UREF; + this->current_reference_ = current_transformer ? BL0939_IREF_CT : BL0939_IREF; + this->energy_reference_ = current_transformer ? BL0939_EREF_CT : BL0939_EREF; + } void loop() override; @@ -84,6 +113,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { sensor::Sensor *energy_sensor_1_{nullptr}; sensor::Sensor *energy_sensor_2_{nullptr}; sensor::Sensor *energy_sensor_sum_{nullptr}; + sensor::Sensor *internal_temperature_sensor_{nullptr}; // Divide by this to turn into Watt float power_reference_ = BL0939_PREF; @@ -93,6 +123,7 @@ class BL0939 final : public PollingComponent, public uart::UARTDevice { float current_reference_ = BL0939_IREF; // Divide by this to turn into kWh float energy_reference_ = BL0939_EREF; + BL0939Mode mode_{BL0939Mode::BL0939_MODE_DIRECT}; static uint32_t to_uint32_t(ube24_t input); diff --git a/esphome/components/bl0939/sensor.py b/esphome/components/bl0939/sensor.py index ec17ef2c7e..248002d185 100644 --- a/esphome/components/bl0939/sensor.py +++ b/esphome/components/bl0939/sensor.py @@ -3,14 +3,19 @@ from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_INTERNAL_TEMPERATURE, + CONF_MODE, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_CELSIUS, UNIT_KILOWATT_HOURS, UNIT_VOLT, UNIT_WATT, @@ -29,11 +34,22 @@ CONF_ENERGY_TOTAL = "energy_total" bl0939_ns = cg.esphome_ns.namespace("bl0939") BL0939 = bl0939_ns.class_("BL0939", cg.PollingComponent, uart.UARTDevice) +BL0939Mode = bl0939_ns.enum("BL0939Mode", is_class=True) +MODE_OPTIONS = { + "direct": BL0939Mode.BL0939_MODE_DIRECT, + "current_transformer": BL0939Mode.BL0939_MODE_CURRENT_TRANSFORMER, +} +# Must match the mode_ member initializer in bl0939.h; set_mode() is skipped +# when the config already equals it. +DEFAULT_MODE = "direct" CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(BL0939), + cv.Optional(CONF_MODE, default=DEFAULT_MODE): cv.enum( + MODE_OPTIONS, lower=True + ), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, @@ -82,6 +98,13 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional(CONF_INTERNAL_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ) .extend(cv.polling_component_schema("60s")) @@ -94,27 +117,16 @@ 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)) + if (mode := config[CONF_MODE]) != DEFAULT_MODE: + cg.add(var.set_mode(mode)) + + 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) + await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor) diff --git a/esphome/components/bl0940/__init__.py b/esphome/components/bl0940/__init__.py index 066c2818b6..953824be51 100644 --- a/esphome/components/bl0940/__init__.py +++ b/esphome/components/bl0940/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@tobias-", "@dan-s-github"] +DOMAIN = "bl0940" CONF_BL0940_ID = "bl0940_id" bl0940_ns = cg.esphome_ns.namespace("bl0940") diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp index 642368d93f..ac1386df03 100644 --- a/esphome/components/bl0940/bl0940.cpp +++ b/esphome/components/bl0940/bl0940.cpp @@ -4,7 +4,7 @@ namespace esphome::bl0940 { -static const char *const TAG = "bl0940"; +ESPHOME_LOG_TAG(TAG, "bl0940"); static const uint8_t BL0940_FULL_PACKET = 0xAA; static const uint8_t BL0940_PACKET_HEADER = 0x55; // 0x58 according to en doc but 0x55 in cn doc @@ -44,13 +44,9 @@ void BL0940::loop() { } bool BL0940::validate_checksum_(DataPacket *data) { - uint8_t checksum = this->read_command_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, this->read_command_) ^ 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "Invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } diff --git a/esphome/components/bl0940/button/calibration_reset_button.cpp b/esphome/components/bl0940/button/calibration_reset_button.cpp index a0b41b9a5b..5598818f54 100644 --- a/esphome/components/bl0940/button/calibration_reset_button.cpp +++ b/esphome/components/bl0940/button/calibration_reset_button.cpp @@ -6,7 +6,7 @@ namespace esphome::bl0940 { -static const char *const TAG = "bl0940.button.calibration_reset"; +ESPHOME_LOG_TAG(TAG, "bl0940.button.calibration_reset"); void CalibrationResetButton::dump_config() { LOG_BUTTON("", "Calibration Reset Button", this); } diff --git a/esphome/components/bl0940/number/calibration_number.cpp b/esphome/components/bl0940/number/calibration_number.cpp index 6f054443a2..d382a12e69 100644 --- a/esphome/components/bl0940/number/calibration_number.cpp +++ b/esphome/components/bl0940/number/calibration_number.cpp @@ -3,7 +3,7 @@ namespace esphome::bl0940 { -static const char *const TAG = "bl0940.number"; +ESPHOME_LOG_TAG(TAG, "bl0940.number"); void CalibrationNumber::setup() { float value = 0.0f; 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/__init__.py b/esphome/components/bl0942/__init__.py index 38b68d84b5..89141daae3 100644 --- a/esphome/components/bl0942/__init__.py +++ b/esphome/components/bl0942/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@dbuezas", "@dwmw2"] +DOMAIN = "bl0942" diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp index e952df21be..7da8dcca54 100644 --- a/esphome/components/bl0942/bl0942.cpp +++ b/esphome/components/bl0942/bl0942.cpp @@ -6,7 +6,7 @@ namespace esphome::bl0942 { -static const char *const TAG = "bl0942"; +ESPHOME_LOG_TAG(TAG, "bl0942"); static const uint8_t BL0942_READ_COMMAND = 0x58; static const uint8_t BL0942_FULL_PACKET = 0xAA; @@ -70,13 +70,10 @@ void BL0942::loop() { } bool BL0942::validate_checksum_(DataPacket *data) { - uint8_t checksum = BL0942_READ_COMMAND | this->address_; // Whole package but checksum - uint8_t *raw = (uint8_t *) data; - for (uint32_t i = 0; i < sizeof(*data) - 1; i++) { - checksum += raw[i]; - } - checksum ^= 0xFF; + const uint8_t checksum = + sum_bytes(reinterpret_cast(data), sizeof(*data) - 1, BL0942_READ_COMMAND | this->address_) ^ + 0xFF; if (checksum != data->checksum) { ESP_LOGW(TAG, "BL0942 invalid checksum! 0x%02X != 0x%02X", checksum, data->checksum); } 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..b783ef1c15 100644 --- a/esphome/components/ble_client/__init__.py +++ b/esphome/components/ble_client/__init__.py @@ -22,6 +22,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble_client"] CODEOWNERS = ["@buxtronix", "@clydebarrow"] DEPENDENCIES = ["esp32_ble_tracker"] +DOMAIN = "ble_client" CONF_DESCRIPTOR_UUID = "descriptor_uuid" CONF_ON_NOTIFY = "on_notify" @@ -207,26 +208,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 +323,12 @@ async def passkey_reply_to_code(config, action_id, template_arg, args): return var -@automation.register_action( +automation.register_simple_action( "ble_client.remove_bond", BLERemoveBondAction, BLE_REMOVE_BOND_ACTION_SCHEMA, synchronous=True, ) -async def remove_bond_to_code(config, action_id, template_arg, args): - parent = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, parent) async def to_code(config): diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index 25001c8f74..eb24cdeeb4 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -9,7 +9,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_client"; +ESPHOME_LOG_TAG(TAG, "ble_client"); void BLEClient::setup() { BLEClientBase::setup(); diff --git a/esphome/components/ble_client/output/ble_binary_output.cpp b/esphome/components/ble_client/output/ble_binary_output.cpp index 5d53c59708..479bfefd59 100644 --- a/esphome/components/ble_client/output/ble_binary_output.cpp +++ b/esphome/components/ble_client/output/ble_binary_output.cpp @@ -5,7 +5,7 @@ #ifdef USE_ESP32 namespace esphome::ble_client { -static const char *const TAG = "ble_binary_output"; +ESPHOME_LOG_TAG(TAG, "ble_binary_output"); void BLEBinaryOutput::dump_config() { ESP_LOGCONFIG(TAG, "BLE Binary Output:"); diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp index 715298e592..69e7450dc2 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_rssi_sensor"; +ESPHOME_LOG_TAG(TAG, "ble_rssi_sensor"); void BLEClientRSSISensor::loop() { // Parent BLEClientNode has a loop() method, but this component uses diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 5dbb7e42ed..1ddd50c307 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_sensor"; +ESPHOME_LOG_TAG(TAG, "ble_sensor"); void BLESensor::loop() { // Parent BLEClientNode has a loop() method, but this component uses diff --git a/esphome/components/ble_client/switch/ble_switch.cpp b/esphome/components/ble_client/switch/ble_switch.cpp index 5baca2adcf..1edd42760e 100644 --- a/esphome/components/ble_client/switch/ble_switch.cpp +++ b/esphome/components/ble_client/switch/ble_switch.cpp @@ -6,7 +6,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_switch"; +ESPHOME_LOG_TAG(TAG, "ble_switch"); void BLEClientSwitch::write_state(bool state) { this->parent_->set_enabled(state); diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index ed2b0a63a0..f6df7b4336 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -9,7 +9,7 @@ namespace esphome::ble_client { -static const char *const TAG = "ble_text_sensor"; +ESPHOME_LOG_TAG(TAG, "ble_text_sensor"); void BLETextSensor::loop() { // Parent BLEClientNode has a loop() method, but this component uses diff --git a/esphome/components/ble_device_base/__init__.py b/esphome/components/ble_device_base/__init__.py index 43ec736727..e8283457c6 100644 --- a/esphome/components/ble_device_base/__init__.py +++ b/esphome/components/ble_device_base/__init__.py @@ -41,6 +41,7 @@ from esphome.core import CORE, ID, KEY_CORE, TimePeriod from esphome.types import ConfigType CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "ble_device_base" CONF_BLE_HUB_ID = "ble_hub_id" 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_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index 23ca6b1dbd..f7fa488aac 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -16,7 +16,7 @@ namespace esphome::ble_device_base { -static const char *const TAG = "ble_device_base"; +ESPHOME_LOG_TAG(TAG, "ble_device_base"); // Longest advertisement payload worth hex-dumping at VERY_VERBOSE // (legacy advertising: 31-byte adv + 31-byte scan response). diff --git a/esphome/components/ble_nus/__init__.py b/esphome/components/ble_nus/__init__.py index c0837da402..5d43162241 100644 --- a/esphome/components/ble_nus/__init__.py +++ b/esphome/components/ble_nus/__init__.py @@ -3,10 +3,12 @@ from esphome.components.logger import request_log_listener from esphome.components.uart import ( UARTComponent, debug_to_code, + mark_unclocked, maybe_empty_debug, 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, @@ -20,6 +22,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["zephyr_ble_server", "uart"] CODEOWNERS = ["@tomaszduda23"] +DOMAIN = "ble_nus" ble_nus_ns = cg.esphome_ns.namespace("ble_nus") BLENUS = ble_nus_ns.class_("BLENUS", cg.Component, UARTComponent) @@ -40,7 +43,7 @@ def validate_rx_buffer(config: ConfigType) -> ConfigType: CONFIG_SCHEMA = cv.All( cv.Schema( { - cv.GenerateID(): cv.declare_id(BLENUS), + cv.GenerateID(): cv.All(cv.declare_id(BLENUS), mark_unclocked), cv.Optional(CONF_TYPE, default=CONF_LOGS): cv.one_of( *[CONF_LOGS, CONF_UART], lower=True ), @@ -61,6 +64,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/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index b566122f8a..64a36c468f 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -19,7 +19,7 @@ RING_BUF_DECLARE(global_ble_rx_ring_buf, ESPHOME_BLE_NUS_RX_RING_BUFFER_SIZE); #endif // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) -static const char *const TAG = "ble_nus"; +ESPHOME_LOG_TAG(TAG, "ble_nus"); void BLENUS::write_array(const uint8_t *data, size_t len) { if (atomic_get(&this->tx_status_) == TX_DISABLED) { diff --git a/esphome/components/ble_presence/__init__.py b/esphome/components/ble_presence/__init__.py index e69de29bb2..0be32a6e95 100644 --- a/esphome/components/ble_presence/__init__.py +++ b/esphome/components/ble_presence/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ble_presence" diff --git a/esphome/components/ble_presence/ble_presence_device.cpp b/esphome/components/ble_presence/ble_presence_device.cpp index bc169623ce..91947ccd26 100644 --- a/esphome/components/ble_presence/ble_presence_device.cpp +++ b/esphome/components/ble_presence/ble_presence_device.cpp @@ -3,7 +3,7 @@ namespace esphome::ble_presence { -static const char *const TAG = "ble_presence"; +ESPHOME_LOG_TAG(TAG, "ble_presence"); void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); } diff --git a/esphome/components/ble_rssi/__init__.py b/esphome/components/ble_rssi/__init__.py index e69de29bb2..2d31b9aec2 100644 --- a/esphome/components/ble_rssi/__init__.py +++ b/esphome/components/ble_rssi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ble_rssi" diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.cpp b/esphome/components/ble_rssi/ble_rssi_sensor.cpp index 7c7c7b2148..eb049313a8 100644 --- a/esphome/components/ble_rssi/ble_rssi_sensor.cpp +++ b/esphome/components/ble_rssi/ble_rssi_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::ble_rssi { -static const char *const TAG = "ble_rssi"; +ESPHOME_LOG_TAG(TAG, "ble_rssi"); void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); } diff --git a/esphome/components/ble_scanner/__init__.py b/esphome/components/ble_scanner/__init__.py index e69de29bb2..e687d58e65 100644 --- a/esphome/components/ble_scanner/__init__.py +++ b/esphome/components/ble_scanner/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ble_scanner" diff --git a/esphome/components/ble_scanner/ble_scanner.cpp b/esphome/components/ble_scanner/ble_scanner.cpp index 3d7a301793..42a691b1d9 100644 --- a/esphome/components/ble_scanner/ble_scanner.cpp +++ b/esphome/components/ble_scanner/ble_scanner.cpp @@ -3,7 +3,7 @@ namespace esphome::ble_scanner { -static const char *const TAG = "ble_scanner"; +ESPHOME_LOG_TAG(TAG, "ble_scanner"); void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); } diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index a001729083..a99a39bc9e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -12,7 +12,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection"; +ESPHOME_LOG_TAG(TAG, "bluetooth_connection"); BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, uint8_t connection_index, const char *address_str) { diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 986a67c7a8..7b66c67183 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -17,10 +17,17 @@ #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 { -static const char *const TAG = "bluetooth_connection"; +ESPHOME_LOG_TAG(TAG, "bluetooth_connection"); using ble_device_base::FAST_CONN_TIMEOUT; using ble_device_base::FAST_MAX_CONN_INTERVAL; @@ -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 a4e9edec23..ef0ca223cb 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -100,6 +100,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public int handle_search_cmpl_(esp_gatt_status_t status); void deliver_pending_search_(); void unconditional_disconnect_(); + void cancel_pending_open_(); void set_idle_(); void set_disconnecting_(); esp_err_t update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, uint16_t timeout, @@ -133,6 +134,9 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public bool seen_mtu_ : 1 {false}; // The MTU request was refused at CONNECT_EVT; OPEN_EVT reports instead. bool mtu_failed_ : 1 {false}; + // esp_ble_gattc_cancel_open() was accepted; CANCEL_OPEN_EVT or OPEN_EVT ends + // the attempt, so the scheduled teardown can no longer be cancelled. + bool cancel_open_sent_ : 1 {false}; // Search issued at OPEN_EVT overlaps the MTU exchange; discover_services() // completes from it. Reset by set_idle_(). static_assert(static_cast(SearchState::REPORT_PENDING) < (1 << 4), "search_state_ bitfield too narrow"); diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index ddab4812cc..5e358175bf 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -31,7 +31,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection"; +ESPHOME_LOG_TAG(TAG, "bluetooth_connection"); void BluetoothConnection::set_address(uint64_t address) { // Keep the proxy's pre-allocated connections-free message in step diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index eec2c8c318..c6e2703c48 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -15,7 +15,7 @@ namespace esphome::bluetooth_connection { -static const char *const TAG = "bluetooth_connection"; +ESPHOME_LOG_TAG(TAG, "bluetooth_connection"); using ble_device_base::ESPBTUUID; using ble_device_base::GATT_ERR_NOT_CONNECTED; diff --git a/esphome/components/bluetooth_proxy/__init__.py b/esphome/components/bluetooth_proxy/__init__.py index c87ad7f595..6332ea6dc1 100644 --- a/esphome/components/bluetooth_proxy/__init__.py +++ b/esphome/components/bluetooth_proxy/__init__.py @@ -58,6 +58,7 @@ _HUB_PLATFORMS = (PLATFORM_BK72XX, PLATFORM_LN882X, PLATFORM_RP2) DEPENDENCIES = ["api"] CODEOWNERS = ["@jesserockz", "@bdraco"] +DOMAIN = "bluetooth_proxy" _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index cb37057cd4..e4b9487074 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -14,7 +14,7 @@ namespace esphome::bluetooth_proxy { -static const char *const TAG = "bluetooth_proxy"; +ESPHOME_LOG_TAG(TAG, "bluetooth_proxy"); // BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE is defined during code generation // It sets the batch size for BLE advertisements to maximize WiFi efficiency diff --git a/esphome/components/bm8563/__init__.py b/esphome/components/bm8563/__init__.py index 20254a8b00..c3d1fd8cf6 100644 --- a/esphome/components/bm8563/__init__.py +++ b/esphome/components/bm8563/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@abmantis"] +DOMAIN = "bm8563" diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp index d911301c9d..df1cf96d69 100644 --- a/esphome/components/bm8563/bm8563.cpp +++ b/esphome/components/bm8563/bm8563.cpp @@ -6,7 +6,7 @@ namespace esphome::bm8563 { -static const char *const TAG = "bm8563"; +ESPHOME_LOG_TAG(TAG, "bm8563"); static constexpr uint8_t CONTROL_STATUS_1_REG = 0x00; static constexpr uint8_t CONTROL_STATUS_2_REG = 0x01; 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..8d54cbed2a 100644 --- a/esphome/components/bme280_base/__init__.py +++ b/esphome/components/bme280_base/__init__.py @@ -20,6 +20,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "bme280_base" bme280_ns = cg.esphome_ns.namespace("bme280_base") BME280Oversampling = bme280_ns.enum("BME280Oversampling") @@ -90,20 +91,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 11c796352a..96bb56f345 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -11,7 +11,9 @@ namespace esphome::bme280_base { -static const char *const TAG = "bme280.sensor"; +ESPHOME_LOG_TAG(TAG, "bme280.sensor"); + +static constexpr uint32_t DATA_TIMEOUT_ID = 0; static const uint8_t BME280_REGISTER_DIG_T1 = 0x88; static const uint8_t BME280_REGISTER_DIG_T2 = 0x8A; @@ -221,7 +223,7 @@ void BME280Component::update() { meas_time += 2.3f * oversampling_to_time(this->pressure_oversampling_) + 0.575f; meas_time += 2.3f * oversampling_to_time(this->humidity_oversampling_) + 0.575f; - this->set_timeout("data", uint32_t(ceilf(meas_time)), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ceilf(meas_time)), [this]() { uint8_t data[8]; if (!this->read_bytes(BME280_REGISTER_MEASUREMENTS, data, 8)) { ESP_LOGW(TAG, "Error reading registers"); diff --git a/esphome/components/bme280_i2c/__init__.py b/esphome/components/bme280_i2c/__init__.py index e69de29bb2..d57004f5fb 100644 --- a/esphome/components/bme280_i2c/__init__.py +++ b/esphome/components/bme280_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bme280_i2c" diff --git a/esphome/components/bme280_spi/__init__.py b/esphome/components/bme280_spi/__init__.py index e69de29bb2..5ca2ba4595 100644 --- a/esphome/components/bme280_spi/__init__.py +++ b/esphome/components/bme280_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bme280_spi" diff --git a/esphome/components/bme680/__init__.py b/esphome/components/bme680/__init__.py index e69de29bb2..1ebff77202 100644 --- a/esphome/components/bme680/__init__.py +++ b/esphome/components/bme680/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bme680" diff --git a/esphome/components/bme680/bme680.cpp b/esphome/components/bme680/bme680.cpp index bac8ed8a5a..6bacb71ff2 100644 --- a/esphome/components/bme680/bme680.cpp +++ b/esphome/components/bme680/bme680.cpp @@ -4,7 +4,9 @@ namespace esphome::bme680 { -static const char *const TAG = "bme680.sensor"; +ESPHOME_LOG_TAG(TAG, "bme680.sensor"); + +static constexpr uint32_t DATA_TIMEOUT_ID = 0; static const uint8_t BME680_REGISTER_COEFF1 = 0x89; static const uint8_t BME680_REGISTER_COEFF2 = 0xE1; @@ -242,7 +244,7 @@ void BME680Component::update() { return; } - this->set_timeout("data", this->calc_meas_duration_(), [this]() { this->read_data_(); }); + this->set_timeout(DATA_TIMEOUT_ID, this->calc_meas_duration_(), [this]() { this->read_data_(); }); } uint8_t BME680Component::calc_heater_resistance_(uint16_t temperature) { 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/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 35df2a7ea3..190994b2f8 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -9,6 +9,7 @@ CODEOWNERS = ["@trvrnrth"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["sensor", "text_sensor"] CONFLICTS_WITH = ["bme68x_bsec2"] +DOMAIN = "bme680_bsec" MULTI_CONF = True CONF_BME680_BSEC_ID = "bme680_bsec_id" diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp index 8e16a28e33..3f0082e1af 100644 --- a/esphome/components/bme680_bsec/bme680_bsec.cpp +++ b/esphome/components/bme680_bsec/bme680_bsec.cpp @@ -5,7 +5,9 @@ namespace esphome::bme680_bsec { #ifdef USE_BSEC -static const char *const TAG = "bme680_bsec.sensor"; +ESPHOME_LOG_TAG(TAG, "bme680_bsec.sensor"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; static const std::string IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; @@ -259,7 +261,7 @@ void BME680BSECComponent::run_() { this->snapshot_state_(); ESP_LOGV(TAG, "Queueing read in %ums", meas_dur); - this->set_timeout("read", meas_dur, [this]() { this->read_(); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur, [this]() { this->read_(); }); } else { ESP_LOGV(TAG, "Measurement not required"); this->read_(); diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index d4ac57d750..f5a674d6b9 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -13,7 +13,9 @@ namespace esphome::bme68x_bsec2 { #define BME68X_BSEC2_SAMPLE_RATE_LOG(r) (r == SAMPLE_RATE_DEFAULT ? "Default" : (r == SAMPLE_RATE_ULP ? "ULP" : "LP")) #define BME68X_BSEC2_VOLTAGE_LOG(v) (v == VOLTAGE_3_3V ? "3.3V" : "1.8V") -static const char *const TAG = "bme68x_bsec2.sensor"; +ESPHOME_LOG_TAG(TAG, "bme68x_bsec2.sensor"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; static constexpr const char *const IAQ_ACCURACY_STATES[4] = {"Stabilizing", "Uncertain", "Calibrating", "Calibrated"}; @@ -285,11 +287,11 @@ void BME68xBSEC2Component::run_() { uint32_t meas_dur = bme68x_get_meas_dur(this->op_mode_, &bme68x_conf, &this->bme68x_); ESP_LOGV(TAG, "Queueing read in %" PRIu32 "us", meas_dur); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout("read", meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, meas_dur / 1000, [this]() { this->read_(this->trigger_time_ns_); }); } else { ESP_LOGV(TAG, "Measurement not required, queueing immediate read"); this->trigger_time_ns_ = curr_time_ns; - this->set_timeout("read", 0, [this]() { this->read_(this->trigger_time_ns_); }); + this->set_timeout(READ_TIMEOUT_ID, 0, [this]() { this->read_(this->trigger_time_ns_); }); } } diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index 1da3bdbd6c..8c50f6e165 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -9,6 +9,7 @@ import esphome.config_validation as cv from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] +DOMAIN = "bme68x_bsec2_i2c" AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] diff --git a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp index 2a2c5e2bcc..8672e4f6ff 100644 --- a/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp +++ b/esphome/components/bme68x_bsec2_i2c/bme68x_bsec2_i2c.cpp @@ -11,7 +11,7 @@ namespace esphome::bme68x_bsec2_i2c { -static const char *const TAG = "bme68x_bsec2_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bme68x_bsec2_i2c.sensor"); void BME68xBSEC2I2CComponent::setup() { // must set up our bme68x_dev instance before calling setup() diff --git a/esphome/components/bmi160/__init__.py b/esphome/components/bmi160/__init__.py index 49b6d0252a..75386e9857 100644 --- a/esphome/components/bmi160/__init__.py +++ b/esphome/components/bmi160/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@flaviut"] +DOMAIN = "bmi160" diff --git a/esphome/components/bmi160/bmi160.cpp b/esphome/components/bmi160/bmi160.cpp index 442ee183bf..a90d490ae1 100644 --- a/esphome/components/bmi160/bmi160.cpp +++ b/esphome/components/bmi160/bmi160.cpp @@ -4,7 +4,7 @@ namespace esphome::bmi160 { -static const char *const TAG = "bmi160"; +ESPHOME_LOG_TAG(TAG, "bmi160"); static constexpr uint32_t GYRO_WAKEUP_TIMEOUT_MS = 100; const uint8_t BMI160_REGISTER_CHIPID = 0x00; 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/bmi270/__init__.py b/esphome/components/bmi270/__init__.py index 0e67e41a0e..499e88b5b1 100644 --- a/esphome/components/bmi270/__init__.py +++ b/esphome/components/bmi270/__init__.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.motion import MotionComponent CODEOWNERS = ["@clydebarrow"] +DOMAIN = "bmi270" CONF_BMI270_ID = "bmi270_id" # C++ namespace / class diff --git a/esphome/components/bmi270/bmi270.cpp b/esphome/components/bmi270/bmi270.cpp index acb93158d4..37f718fb7e 100644 --- a/esphome/components/bmi270/bmi270.cpp +++ b/esphome/components/bmi270/bmi270.cpp @@ -5,12 +5,16 @@ namespace esphome::bmi270 { -static const char *const TAG = "bmi270"; +#ifdef USE_BMI270_BMM150 +using namespace bmm150; // NOLINT - sole consumer of the BMM150 aux-device definitions +#endif + +ESPHOME_LOG_TAG(TAG, "bmi270"); #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const size_t MAX_I2C_BUFFER_SIZE = 32; +static constexpr size_t MAX_I2C_BUFFER_SIZE = 32; #else -static const size_t MAX_I2C_BUFFER_SIZE = 256; +static constexpr size_t MAX_I2C_BUFFER_SIZE = 256; #endif // Configuration blob upload @@ -19,12 +23,12 @@ static const size_t MAX_I2C_BUFFER_SIZE = 256; bool BMI270Component::load_config_file_() { // 1. Disable advanced power-save so the config port is accessible - if (!this->write_byte(BMI270_REG_PWR_CONF, 0x00)) + if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_ADV_POWER_SAVE_OFF)) return false; delay(1); // 2. Prepare config load: write 0x00 to INIT_CTRL to start - if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x00)) + if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_START)) return false; // 3. Burst-write the config in pages @@ -50,7 +54,7 @@ bool BMI270Component::load_config_file_() { } // 4. Signal end of config load - if (!this->write_byte(BMI270_REG_INIT_CTRL, 0x01)) + if (!this->write_byte(BMI270_REG_INIT_CTRL, BMI270_INIT_CTRL_LOAD_DONE)) return false; delay(20); // spec: wait ≥20 ms for init to complete @@ -58,7 +62,7 @@ bool BMI270Component::load_config_file_() { uint8_t status = 0; if (!this->read_byte(BMI270_REG_INTERNAL_STATUS, &status)) return false; - if ((status & 0x0F) != 0x01) { + if ((status & BMI270_INTERNAL_STATUS_MASK) != BMI270_INTERNAL_STATUS_INIT_OK) { ESP_LOGE(TAG, "Config load failed: INTERNAL_STATUS=0x%02X (expected 0x01)", status); return false; } @@ -84,7 +88,7 @@ void BMI270Component::setup() { ESP_LOGD(TAG, "Chip ID: 0x%02X", chip_id); // 2. Soft-reset via CMD register (0x7E = 0xB6) - if (!this->write_byte(0x7E, 0xB6)) { + if (!this->write_byte(BMI270_REG_CMD, BMI270_CMD_SOFT_RESET)) { this->mark_failed(); return; } @@ -100,7 +104,8 @@ void BMI270Component::setup() { // 5. Configure accelerometer // ACC_CONF: ODR | BWP(0x2 = normal avg4) | perf_mode(1) - uint8_t acc_conf = (uint8_t) (accel_odr_) | (0x2 << 4) | (1 << 7); + uint8_t acc_conf = + (uint8_t) (accel_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | BMI270_ACC_CONF_PERF_MODE; if (!this->write_byte(BMI270_REG_ACC_CONF, acc_conf)) { this->mark_failed(); return; @@ -112,7 +117,8 @@ void BMI270Component::setup() { // 6. Configure gyroscope // GYR_CONF: ODR | BWP(0x2 = normal) | noise_perf(1) | filter_perf(1) - uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (0x2 << 4) | (1 << 6) | (1 << 7); + uint8_t gyr_conf = (uint8_t) (gyro_odr_) | (BMI270_CONF_BWP_NORMAL << BMI270_CONF_BWP_SHIFT) | + BMI270_GYR_CONF_NOISE_PERF | BMI270_GYR_CONF_FILTER_PERF; if (!this->write_byte(BMI270_REG_GYR_CONF, gyr_conf)) { this->mark_failed(); return; @@ -124,7 +130,7 @@ void BMI270Component::setup() { // 7. Enable accelerometer, gyroscope, and temperature sensor // PWR_CTRL bits: temp_en[3] | gyr_en[2] | acc_en[1] - if (!this->write_byte(BMI270_REG_PWR_CTRL, 0x0E)) { + if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU)) { this->mark_failed(); return; } @@ -132,14 +138,133 @@ void BMI270Component::setup() { // 8. Re-enable advanced power save (optional; keeps current low between reads) // Disabled here for simplicity – leave in performance mode - if (!this->write_byte(BMI270_REG_PWR_CONF, 0x02)) { // bit1 = fifo_self_wakeup + if (!this->write_byte(BMI270_REG_PWR_CONF, BMI270_PWR_CONF_FIFO_SELF_WAKEUP)) { // bit1 = fifo_self_wakeup this->mark_failed(); return; } + // 9. Optionally bring up whatever is wired to the aux interface. + // This is entirely additive: it is off by default, and failure only disables + // magnetometer reporting rather than the whole component. +#ifdef USE_BMI270_BMM150 + if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { + this->magnetometer_ready_ = this->setup_magnetometer_(); + if (!this->magnetometer_ready_) { + ESP_LOGW(TAG, "BMM150 magnetometer setup failed; continuing without it"); + } + } +#endif + ESP_LOGCONFIG(TAG, "BMI270 initialised successfully"); } +#ifdef USE_BMI270_BMM150 +bool BMI270Component::wait_for_aux_idle_(uint32_t timeout_ms) { + // The STATUS aux-busy bit is set while an aux transaction is in flight. Aux transaction + // duration depends on the attached device's own timing, so poll with a bounded + // timeout instead of guessing a fixed delay. + uint32_t start = millis(); + while (true) { + uint8_t status = 0; + if (!this->read_byte(BMI270_REG_STATUS, &status)) + return false; + if ((status & BMI270_STATUS_AUX_BUSY) == 0) + return true; + if (millis() - start >= timeout_ms) + return false; + delay(1); + } +} + +bool BMI270Component::write_aux_register_(uint8_t reg, uint8_t value) { + if (!this->write_byte(BMI270_REG_AUX_WR_DATA, value)) + return false; + if (!this->write_byte(BMI270_REG_AUX_WR_ADDR, reg)) + return false; + return this->wait_for_aux_idle_(20); +} + +bool BMI270Component::read_aux_block_(uint8_t reg, uint8_t *buf) { + if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, reg)) + return false; + if (!this->wait_for_aux_idle_(20)) + return false; + return this->read_bytes(BMI270_REG_AUX_X_LSB, buf, BMI270_AUX_DATA_LEN); +} + +bool BMI270Component::read_magnetometer_trim_() { + uint8_t regs[BMM150_TRIM_BLOCKS * BMM150_TRIM_BLOCK_LEN]; + for (uint8_t i = 0; i < BMM150_TRIM_BLOCKS; i++) { + if (!this->read_aux_block_(BMM150_REG_TRIM_START + i * BMM150_TRIM_BLOCK_LEN, regs + i * BMM150_TRIM_BLOCK_LEN)) + return false; + } + this->mag_trim_ = bmm150_parse_trim(regs); + return true; +} + +bool BMI270Component::setup_magnetometer_() { + // Sequence adapted from the Bosch BMI270 aux-interface protocol: enable the secondary + // I2C master in manual mode, point it at the BMM150's address, power the BMM150 up + // and soft-reset it, verify its chip ID, read its factory trim values, put it in + // normal/30Hz mode, then switch the aux interface to auto-burst mode so BMI270 keeps + // AUX_X/Y/Z/R refreshed from the BMM150's data registers. + ESP_LOGD(TAG, "Setting up BMM150 magnetometer via BMI270 aux interface..."); + + if (!this->write_byte(BMI270_REG_IF_CONF, BMI270_IF_CONF_AUX_IF_EN)) + return false; + if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_MANUAL_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8)) + return false; + if (!this->write_byte(BMI270_REG_AUX_DEV_ID, (uint8_t) (this->aux_device_address_ << 1))) + return false; + + // Suspend -> sleep, then a soft reset so the BMM150 starts from a known state even if it + // stayed powered across a restart. The chip ID is only readable once the power bit is set. + if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_POWER_ON)) { + ESP_LOGW(TAG, "BMM150 power-on write timed out"); + return false; + } + delay(BMM150_START_UP_TIME_MS); + if (!this->write_aux_register_(BMM150_REG_POWER_CONTROL, BMM150_CMD_SOFT_RESET)) { + ESP_LOGW(TAG, "BMM150 soft-reset write timed out"); + return false; + } + delay(BMM150_SOFT_RESET_TIME_MS); + + uint8_t chip_id[BMI270_AUX_DATA_LEN]; + if (!this->read_aux_block_(BMM150_REG_CHIP_ID, chip_id)) { + ESP_LOGW(TAG, "BMM150 chip-ID read failed"); + return false; + } + if (chip_id[0] != BMM150_CHIP_ID_VALUE) { + ESP_LOGW(TAG, "BMM150 not detected (chip ID 0x%02X, expected 0x%02X)", chip_id[0], BMM150_CHIP_ID_VALUE); + return false; + } + + if (!this->read_magnetometer_trim_()) { + ESP_LOGW(TAG, "BMM150 trim data read failed"); + return false; + } + + if (!this->write_aux_register_(BMM150_REG_OP_MODE, BMM150_CMD_NORMAL_MODE_ODR_30HZ)) { + ESP_LOGW(TAG, "BMM150 mode write timed out"); + return false; + } + + // Switch to auto-burst mode: BMI270 will keep polling 8 bytes from the BMM150's + // data registers (0x42) into its own AUX_X/Y/Z/R registers automatically. + if (!this->write_byte(BMI270_REG_AUX_IF_CONF, BMI270_AUX_IF_CONF_FCU_WRITE_EN | BMI270_AUX_IF_CONF_MAN_RD_BURST_8 | + BMI270_AUX_IF_CONF_RD_BURST_8)) + return false; + if (!this->write_byte(BMI270_REG_AUX_RD_ADDR, BMM150_REG_DATA_X_LSB)) + return false; + if (!this->write_byte(BMI270_REG_PWR_CTRL, BMI270_PWR_CTRL_IMU | BMI270_PWR_CTRL_AUX_EN)) + return false; + + ESP_LOGD(TAG, "BMM150 magnetometer ready"); + return true; +} +#endif // USE_BMI270_BMM150 + void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, "BMI270 IMU:"); LOG_I2C_DEVICE(this); @@ -153,6 +278,12 @@ void BMI270Component::dump_config() { ESP_LOGCONFIG(TAG, " Accel range : %s", ACCEL_RANGE_STRS[accel_range_]); ESP_LOGCONFIG(TAG, " Gyro range : %s", GYRO_RANGE_STRS[gyro_range_]); +#ifdef USE_BMI270_BMM150 + if (this->aux_device_ == BMI270_AUX_DEVICE_BMM150) { + ESP_LOGCONFIG(TAG, " Aux device : BMM150 magnetometer (%s)", + this->magnetometer_ready_ ? LOG_STR_LITERAL("ready") : LOG_STR_LITERAL("NOT DETECTED")); + } +#endif MotionComponent::dump_config(); } @@ -163,9 +294,22 @@ bool BMI270Component::update_data(motion::MotionData &data) { if (this->is_failed()) return false; - // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) - uint8_t raw_data[REG_READ_LEN]; - if (!this->read_bytes(BMI270_REG_DATA_8, raw_data, REG_READ_LEN)) { + // Accelerometer: registers 0x0C–0x11 (6 bytes: x_lsb, x_msb, y_lsb, y_msb, z_lsb, z_msb) + // When the magnetometer is active, the 8 aux data bytes (0x04–0x0B) sit directly before + // the accel data, so one burst read from AUX_X_LSB fetches both. +#ifdef USE_BMI270_BMM150 + const bool read_mag = this->magnetometer_ready_ && !this->magnetometer_callback_.empty(); + uint8_t buf[BMI270_AUX_DATA_LEN + REG_READ_LEN]; + const uint8_t *mag_raw = buf; + const uint8_t *raw_data = read_mag ? buf + BMI270_AUX_DATA_LEN : buf; + const bool read_ok = read_mag ? this->read_bytes(BMI270_REG_AUX_X_LSB, buf, sizeof(buf)) + : this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN); +#else + uint8_t buf[REG_READ_LEN]; + const uint8_t *raw_data = buf; + const bool read_ok = this->read_bytes(BMI270_REG_DATA_8, buf, REG_READ_LEN); +#endif + if (!read_ok) { ESP_LOGW(TAG, "Failed to read IMU data"); return false; } @@ -195,14 +339,21 @@ bool BMI270Component::update_data(motion::MotionData &data) { data.angular_rate[motion::Y_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 3] << 8) | raw_data[GYR_OFFS + 2]) * scale; data.angular_rate[motion::Z_AXIS] = (int16_t) ((raw_data[GYR_OFFS + 5] << 8) | raw_data[GYR_OFFS + 4]) * scale; - if (this->temperature_callback_.empty()) - return true; - // Temperature: registers 0x22–0x23 - // Formula from datasheet: T[°C] = raw / 512 + 23 - static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; - int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); - float temperature = (raw_t / 512.0f) + 23.0f; - this->temperature_callback_.call(temperature); + if (!this->temperature_callback_.empty()) { + // Temperature: registers 0x22–0x23 + // Formula from datasheet: T[°C] = raw / 512 + 23 + static constexpr uint8_t TEMP_OFFS = BMI270_REG_TEMP_0 - BMI270_REG_DATA_8; + int16_t raw_t = (int16_t) ((raw_data[TEMP_OFFS + 1] << 8) | raw_data[TEMP_OFFS + 0]); + float temperature = (raw_t / 512.0f) + 23.0f; + this->temperature_callback_.call(temperature); + } + +#ifdef USE_BMI270_BMM150 + if (read_mag) { + const BMM150Data mag_data = bmm150_convert(this->mag_trim_, mag_raw); + this->magnetometer_callback_.call(mag_data); + } +#endif return true; } diff --git a/esphome/components/bmi270/bmi270.h b/esphome/components/bmi270/bmi270.h index 56d6a60952..a1fefdffe8 100644 --- a/esphome/components/bmi270/bmi270.h +++ b/esphome/components/bmi270/bmi270.h @@ -4,35 +4,81 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/i2c/i2c.h" +#include "esphome/core/defines.h" +#ifdef USE_BMI270_BMM150 +#include "bmm150_aux.h" +#endif #include namespace esphome::bmi270 { // Register map -static const uint8_t BMI270_REG_CHIP_ID = 0x00; -static const uint8_t BMI270_REG_ERR_REG = 0x02; -static const uint8_t BMI270_REG_STATUS = 0x03; -static const uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB -static const uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB -static const uint8_t BMI270_REG_TEMP_0 = 0x22; -static const uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) +static constexpr uint8_t BMI270_REG_CHIP_ID = 0x00; +static constexpr uint8_t BMI270_REG_ERR_REG = 0x02; +static constexpr uint8_t BMI270_REG_STATUS = 0x03; +static constexpr uint8_t BMI270_REG_DATA_8 = 0x0C; // ACC_X LSB +static constexpr uint8_t BMI270_REG_DATA_14 = 0x12; // GYR_X LSB +static constexpr uint8_t BMI270_REG_TEMP_0 = 0x22; +static constexpr uint8_t BMI270_REG_TEMP_MSB = 0x23; // temperature (2 bytes big-endian ish) static constexpr uint8_t REG_READ_LEN = BMI270_REG_TEMP_MSB - BMI270_REG_DATA_8 + 1; // 0x23 - 0x0C + 1 = 0x18 bytes total for accel(6) + gyro(6) + temp(2) + padding(4) -static const uint8_t BMI270_REG_PWR_CONF = 0x7C; -static const uint8_t BMI270_REG_PWR_CTRL = 0x7D; -static const uint8_t BMI270_REG_INIT_CTRL = 0x59; -static const uint8_t BMI270_REG_INIT_DATA = 0x5E; -static const uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; -static const uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; -static const uint8_t BMI270_REG_ACC_CONF = 0x40; -static const uint8_t BMI270_REG_ACC_RANGE = 0x41; -static const uint8_t BMI270_REG_GYR_CONF = 0x42; -static const uint8_t BMI270_REG_GYR_RANGE = 0x43; +static constexpr uint8_t BMI270_REG_PWR_CONF = 0x7C; +static constexpr uint8_t BMI270_REG_PWR_CTRL = 0x7D; +static constexpr uint8_t BMI270_REG_INIT_CTRL = 0x59; +static constexpr uint8_t BMI270_REG_INIT_DATA = 0x5E; +static constexpr uint8_t BMI270_REG_INIT_ADDR_0 = 0x5B; +static constexpr uint8_t BMI270_REG_INTERNAL_STATUS = 0x21; +static constexpr uint8_t BMI270_REG_ACC_CONF = 0x40; +static constexpr uint8_t BMI270_REG_ACC_RANGE = 0x41; +static constexpr uint8_t BMI270_REG_GYR_CONF = 0x42; +static constexpr uint8_t BMI270_REG_GYR_RANGE = 0x43; -static const uint8_t BMI270_CHIP_ID_VALUE = 0x24; +static constexpr uint8_t BMI270_CHIP_ID_VALUE = 0x24; + +// Auxiliary (secondary I2C master) interface registers. +// Used to optionally drive a BMM150 magnetometer wired to the BMI270's AUX pins +// instead of the main I2C bus - the BMM150 has no address of its own on that bus. +static constexpr uint8_t BMI270_REG_AUX_X_LSB = 0x04; // start of aux data: X,Y,Z,R (8 bytes) +static constexpr uint8_t BMI270_REG_AUX_DEV_ID = 0x4B; +static constexpr uint8_t BMI270_REG_AUX_IF_CONF = 0x4C; +static constexpr uint8_t BMI270_REG_AUX_RD_ADDR = 0x4D; +static constexpr uint8_t BMI270_REG_AUX_WR_ADDR = 0x4E; +static constexpr uint8_t BMI270_REG_AUX_WR_DATA = 0x4F; +static constexpr uint8_t BMI270_REG_IF_CONF = 0x6B; +static constexpr uint8_t BMI270_REG_CMD = 0x7E; + +// Register values and bitfields +static constexpr uint8_t BMI270_CMD_SOFT_RESET = 0xB6; +static constexpr uint8_t BMI270_PWR_CONF_ADV_POWER_SAVE_OFF = 0x00; +static constexpr uint8_t BMI270_PWR_CONF_FIFO_SELF_WAKEUP = 0x02; +static constexpr uint8_t BMI270_INIT_CTRL_LOAD_START = 0x00; +static constexpr uint8_t BMI270_INIT_CTRL_LOAD_DONE = 0x01; +static constexpr uint8_t BMI270_INTERNAL_STATUS_MASK = 0x0F; +static constexpr uint8_t BMI270_INTERNAL_STATUS_INIT_OK = 0x01; +static constexpr uint8_t BMI270_CONF_BWP_NORMAL = 0x02; // filter bandwidth, bits 5:4 +static constexpr uint8_t BMI270_CONF_BWP_SHIFT = 4; +static constexpr uint8_t BMI270_ACC_CONF_PERF_MODE = 0x80; +static constexpr uint8_t BMI270_GYR_CONF_NOISE_PERF = 0x40; +static constexpr uint8_t BMI270_GYR_CONF_FILTER_PERF = 0x80; +static constexpr uint8_t BMI270_STATUS_AUX_BUSY = 0x04; +static constexpr uint8_t BMI270_IF_CONF_AUX_IF_EN = 0x20; +static constexpr uint8_t BMI270_PWR_CTRL_AUX_EN = 0x01; +static constexpr uint8_t BMI270_PWR_CTRL_ACC_EN = 0x02; +static constexpr uint8_t BMI270_PWR_CTRL_GYR_EN = 0x04; +static constexpr uint8_t BMI270_PWR_CTRL_TEMP_EN = 0x08; +static constexpr uint8_t BMI270_PWR_CTRL_IMU = + BMI270_PWR_CTRL_ACC_EN | BMI270_PWR_CTRL_GYR_EN | BMI270_PWR_CTRL_TEMP_EN; +static constexpr uint8_t BMI270_AUX_IF_CONF_MANUAL_EN = 0x80; +static constexpr uint8_t BMI270_AUX_IF_CONF_FCU_WRITE_EN = 0x40; +static constexpr uint8_t BMI270_AUX_IF_CONF_MAN_RD_BURST_8 = 0x0C; // bits 3:2 = 3 +static constexpr uint8_t BMI270_AUX_IF_CONF_RD_BURST_8 = 0x03; // bits 1:0 = 3 + +// AUX_X_LSB..AUX_R_MSB (8 bytes) sit directly before the accel data, so one burst can read both. +static constexpr uint8_t BMI270_AUX_DATA_LEN = 8; +static_assert(BMI270_REG_AUX_X_LSB + BMI270_AUX_DATA_LEN == BMI270_REG_DATA_8, "aux data must precede accel data"); // Accelerometer range options enum BMI270AccelRange : uint8_t { @@ -75,7 +121,14 @@ enum BMI270GyroODR : uint8_t { BMI270_GYRO_ODR_3200 = 0x0D, }; -// ---Data class +#ifdef USE_BMI270_BMM150 +// What is wired to this instance's auxiliary (secondary I2C master) interface; another +// instance in the same build may have nothing attached. +enum BMI270AuxDevice : uint8_t { + BMI270_AUX_DEVICE_NONE = 0, + BMI270_AUX_DEVICE_BMM150 = 1, +}; +#endif // Main component class class BMI270Component final : public motion::MotionComponent, public i2c::I2CDevice { @@ -91,18 +144,43 @@ class BMI270Component final : public motion::MotionComponent, public i2c::I2CDev void set_gyro_range(BMI270GyroRange r) { this->gyro_range_ = r; } void set_gyro_odr(BMI270GyroODR o) { this->gyro_odr_ = o; } template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } +#ifdef USE_BMI270_BMM150 + void set_aux_device(BMI270AuxDevice device) { this->aux_device_ = device; } + void set_aux_device_address(uint8_t address) { this->aux_device_address_ = address; } + template void add_magnetometer_listener(F &&cb) { this->magnetometer_callback_.add(std::forward(cb)); } +#endif protected: bool update_data(motion::MotionData &data) override; bool load_config_file_(); +#ifdef USE_BMI270_BMM150 + // Brings up the BMM150 over the BMI270's aux interface. Optional: on failure this only + // disables magnetometer reporting, it does not fail the whole component (accel/gyro still work). + bool setup_magnetometer_(); + // Blocking poll of the aux-transaction-busy bit; aux transaction time depends on the + // attached device, so this is bounded by retries/timeout rather than a fixed delay. + bool wait_for_aux_idle_(uint32_t timeout_ms); + // Manual-mode aux read of 8 bytes starting at `reg` on the aux device. + bool read_aux_block_(uint8_t reg, uint8_t *buf); + // Manual-mode aux write of one byte to `reg` on the aux device. + bool write_aux_register_(uint8_t reg, uint8_t value); + // Reads the BMM150 factory trim values used to compensate its samples. + bool read_magnetometer_trim_(); +#endif // Config BMI270AccelRange accel_range_{BMI270_ACCEL_RANGE_4G}; BMI270AccelODR accel_odr_{BMI270_ACCEL_ODR_100}; BMI270GyroRange gyro_range_{BMI270_GYRO_RANGE_2000}; BMI270GyroODR gyro_odr_{BMI270_GYRO_ODR_200}; - LazyCallbackManager temperature_callback_{}; +#ifdef USE_BMI270_BMM150 + LazyCallbackManager magnetometer_callback_{}; + bmm150::BMM150Trim mag_trim_{}; + BMI270AuxDevice aux_device_{BMI270_AUX_DEVICE_NONE}; + uint8_t aux_device_address_{bmm150::BMM150_DEFAULT_I2C_ADDRESS}; + bool magnetometer_ready_{false}; +#endif }; } // namespace esphome::bmi270 diff --git a/esphome/components/bmi270/bmm150_aux.h b/esphome/components/bmi270/bmm150_aux.h new file mode 100644 index 0000000000..747a0898fa --- /dev/null +++ b/esphome/components/bmi270/bmm150_aux.h @@ -0,0 +1,123 @@ +#pragma once +#include +#include +#include "esphome/core/helpers.h" + +// BMM150 magnetometer definitions - the chip that can optionally be wired to the BMI270's +// auxiliary (secondary I2C master) interface. Lives in its own header/namespace because this +// is BMM150-specific driver knowledge, not BMI270 knowledge - it just can't be a standalone +// ESPHome component, since the BMM150 has no address of its own on the main I2C bus and is only +// reachable by having the BMI270 proxy register reads/writes through its aux interface. +namespace esphome::bmi270::bmm150 { + +static constexpr uint8_t BMM150_DEFAULT_I2C_ADDRESS = 0x10; +static constexpr uint8_t BMM150_REG_CHIP_ID = 0x40; +static constexpr uint8_t BMM150_REG_DATA_X_LSB = 0x42; +static constexpr uint8_t BMM150_REG_POWER_CONTROL = 0x4B; +static constexpr uint8_t BMM150_REG_OP_MODE = 0x4C; +static constexpr uint8_t BMM150_CHIP_ID_VALUE = 0x32; +static constexpr uint8_t BMM150_CMD_POWER_ON = 0x01; // power control bit: suspend -> sleep (3 ms start-up) +static constexpr uint8_t BMM150_CMD_SOFT_RESET = 0x83; // soft reset bits 7 and 1, power control bit kept set +static constexpr uint8_t BMM150_CMD_NORMAL_MODE_ODR_30HZ = 0x38; // data rate 111b (30 Hz), opmode 00b (normal) +static constexpr uint32_t BMM150_START_UP_TIME_MS = 3; +static constexpr uint32_t BMM150_SOFT_RESET_TIME_MS = 1; + +// Factory trim registers 0x5D-0x71, read once at setup and used to compensate every sample. +static constexpr uint8_t BMM150_REG_TRIM_START = 0x5D; +static constexpr uint8_t BMM150_TRIM_BLOCK_LEN = 8; // one aux burst +static constexpr uint8_t BMM150_TRIM_BLOCKS = 3; // 0x5D, 0x65, 0x6D -> covers 0x5D-0x74 +static constexpr uint8_t BMM150_TRIM_X1 = 0x5D; +static constexpr uint8_t BMM150_TRIM_Y1 = 0x5E; +static constexpr uint8_t BMM150_TRIM_Z4 = 0x62; // 16-bit, LSB first +static constexpr uint8_t BMM150_TRIM_X2 = 0x64; +static constexpr uint8_t BMM150_TRIM_Y2 = 0x65; +static constexpr uint8_t BMM150_TRIM_Z2 = 0x68; // 16-bit +static constexpr uint8_t BMM150_TRIM_Z1 = 0x6A; // 16-bit +static constexpr uint8_t BMM150_TRIM_XYZ1 = 0x6C; // 16-bit, 15 valid bits +static constexpr uint8_t BMM150_TRIM_Z3 = 0x6E; // 16-bit +static constexpr uint8_t BMM150_TRIM_XY2 = 0x70; +static constexpr uint8_t BMM150_TRIM_XY1 = 0x71; +static constexpr uint16_t BMM150_TRIM_XYZ1_MASK = 0x7FFF; + +// Raw ADC values that mean "overflow" per the datasheet. +static constexpr int16_t BMM150_XY_OVERFLOW_ADCVAL = -4096; +static constexpr int16_t BMM150_Z_OVERFLOW_ADCVAL = -16384; + +// Result of a BMM150 magnetometer reading, in µT. An axis is NaN when the sensor reported overflow. +struct BMM150Data { + float x; + float y; + float z; +}; + +// Factory trim values (BMM150 datasheet, compensation section). +struct BMM150Trim { + int8_t x1, y1, x2, y2, xy2; + uint8_t xy1; + int16_t z2, z3, z4; + uint16_t z1, xyz1; +}; + +// A little-endian 16-bit trim word, addressed by its register +inline uint16_t bmm150_trim_word(const uint8_t *regs, uint8_t reg) { + const uint8_t offset = reg - BMM150_REG_TRIM_START; + return encode_uint16(regs[offset + 1], regs[offset]); +} + +// Decodes the trim registers; `regs` holds the bytes of registers 0x5D onwards (at least 21). +inline BMM150Trim bmm150_parse_trim(const uint8_t *regs) { + BMM150Trim trim; + trim.x1 = static_cast(regs[BMM150_TRIM_X1 - BMM150_REG_TRIM_START]); + trim.y1 = static_cast(regs[BMM150_TRIM_Y1 - BMM150_REG_TRIM_START]); + trim.z4 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z4)); + trim.x2 = static_cast(regs[BMM150_TRIM_X2 - BMM150_REG_TRIM_START]); + trim.y2 = static_cast(regs[BMM150_TRIM_Y2 - BMM150_REG_TRIM_START]); + trim.z2 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z2)); + trim.z1 = bmm150_trim_word(regs, BMM150_TRIM_Z1); + trim.xyz1 = bmm150_trim_word(regs, BMM150_TRIM_XYZ1) & BMM150_TRIM_XYZ1_MASK; + trim.z3 = static_cast(bmm150_trim_word(regs, BMM150_TRIM_Z3)); + trim.xy2 = static_cast(regs[BMM150_TRIM_XY2 - BMM150_REG_TRIM_START]); + trim.xy1 = regs[BMM150_TRIM_XY1 - BMM150_REG_TRIM_START]; + return trim; +} + +// Compensates an X or Y sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant). +inline float bmm150_compensate_xy(const BMM150Trim &trim, int8_t trim_a, int8_t trim_b, int16_t raw, uint16_t rhall) { + if (raw == BMM150_XY_OVERFLOW_ADCVAL || rhall == 0 || trim.xyz1 == 0) + return NAN; + float retval = (float) trim.xyz1 * 16384.0f / (float) rhall - 16384.0f; + float comp1 = (float) trim.xy2 * (retval * retval / 268435456.0f); + float comp2 = comp1 + retval * (float) trim.xy1 / 16384.0f; + float comp3 = (float) trim_b + 160.0f; + float comp4 = (float) raw * ((comp2 + 256.0f) * comp3); + return ((comp4 / 8192.0f) + (float) trim_a * 8.0f) / 16.0f; +} + +// Compensates a Z sample; returns µT, or NaN on overflow. Port of the Bosch BMM150 API (float variant). +inline float bmm150_compensate_z(const BMM150Trim &trim, int16_t raw, uint16_t rhall) { + if (raw == BMM150_Z_OVERFLOW_ADCVAL || trim.z2 == 0 || trim.z1 == 0 || trim.xyz1 == 0 || rhall == 0) + return NAN; + float comp0 = (float) raw - (float) trim.z4; + float comp1 = (float) rhall - (float) trim.xyz1; + float comp2 = (float) trim.z3 * comp1; + float comp3 = (float) trim.z1 * (float) rhall / 32768.0f; + float comp4 = (float) trim.z2 + comp3; + float comp5 = comp0 * 131072.0f - comp2; + return (comp5 / (comp4 * 4.0f)) / 16.0f; +} + +// Converts 8 raw bytes from the BMM150 data registers (X, Y, Z, RHALL; 0x42-0x49) to compensated µT. +inline BMM150Data bmm150_convert(const BMM150Trim &trim, const uint8_t *raw) { + // X/Y are 13-bit (LSB byte bits 7:3), Z is 15-bit (bits 7:1), RHALL is 14-bit (bits 7:2). + int16_t x = (int16_t) (((int16_t) (int8_t) raw[1] * 32) | (raw[0] >> 3)); + int16_t y = (int16_t) (((int16_t) (int8_t) raw[3] * 32) | (raw[2] >> 3)); + int16_t z = (int16_t) (((int16_t) (int8_t) raw[5] * 128) | (raw[4] >> 1)); + uint16_t rhall = (uint16_t) (((uint16_t) raw[7] << 6) | (raw[6] >> 2)); + return BMM150Data{ + .x = bmm150_compensate_xy(trim, trim.x1, trim.x2, x, rhall), + .y = bmm150_compensate_xy(trim, trim.y1, trim.y2, y, rhall), + .z = bmm150_compensate_z(trim, z, rhall), + }; +} + +} // namespace esphome::bmi270::bmm150 diff --git a/esphome/components/bmi270/motion.py b/esphome/components/bmi270/motion.py index ad36e592d1..97127fd2dd 100644 --- a/esphome/components/bmi270/motion.py +++ b/esphome/components/bmi270/motion.py @@ -14,7 +14,11 @@ from . import BMI270Component, bmi270_ns DEPENDENCIES = ["i2c"] +CONF_AUX_ADDRESS = "aux_address" +CONF_AUX_DEVICE = "aux_device" + # Enum proxies (must match the C++ enum values exactly) +BMI270AuxDevice = bmi270_ns.enum("BMI270AuxDevice") BMI270AccelRange = bmi270_ns.enum("BMI270AccelRange") ACCEL_RANGE_OPTIONS = { "2G": BMI270AccelRange.BMI270_ACCEL_RANGE_2G, @@ -73,6 +77,13 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_GYROSCOPE_ODR, default="200HZ"): cv.enum( GYRO_ODR_OPTIONS, upper=True ), + # Optional device wired to the BMI270's aux interface (not the main I2C bus). + # Defaults to NONE so existing configs/boards are unaffected. + cv.Optional(CONF_AUX_DEVICE, default="NONE"): cv.one_of( + "NONE", "BMM150", upper=True + ), + # Default matches the BMM150's fixed I2C address; irrelevant when aux_device is NONE. + cv.Optional(CONF_AUX_ADDRESS, default=0x10): cv.i2c_address, } ) .extend(i2c.i2c_device_schema(0x68)) @@ -90,3 +101,9 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_accel_odr(config[CONF_ACCELEROMETER_ODR])) cg.add(var.set_gyro_range(config[CONF_GYROSCOPE_RANGE])) cg.add(var.set_gyro_odr(config[CONF_GYROSCOPE_ODR])) + # The aux interface code only compiles when some instance has a BMM150; the + # instance still records its own device, as another one may have none + if config[CONF_AUX_DEVICE] == "BMM150": + cg.add_define("USE_BMI270_BMM150") + cg.add(var.set_aux_device(BMI270AuxDevice.BMI270_AUX_DEVICE_BMM150)) + cg.add(var.set_aux_device_address(config[CONF_AUX_ADDRESS])) diff --git a/esphome/components/bmi270/sensor.py b/esphome/components/bmi270/sensor.py index 0e1b0604a3..242b86614d 100644 --- a/esphome/components/bmi270/sensor.py +++ b/esphome/components/bmi270/sensor.py @@ -6,37 +6,97 @@ from esphome.const import ( CONF_TEMPERATURE, CONF_TYPE, DEVICE_CLASS_TEMPERATURE, + ICON_MAGNET, ICON_THERMOMETER, STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, + UNIT_MICROTESLA, ) from esphome.cpp_generator import MockObj +import esphome.final_validate as fv from esphome.types import ConfigType -from . import CONF_BMI270_ID, BMI270Component +from . import CONF_BMI270_ID, BMI270Component, bmi270_ns +from .motion import CONF_AUX_DEVICE AUTO_LOAD = ["bmi270"] -CONFIG_SCHEMA = sensor.sensor_schema( - unit_of_measurement=UNIT_CELSIUS, - icon=ICON_THERMOMETER, - accuracy_decimals=2, - state_class=STATE_CLASS_MEASUREMENT, - device_class=DEVICE_CLASS_TEMPERATURE, -).extend( +CONF_MAGNETIC_FIELD_X = "magnetic_field_x" +CONF_MAGNETIC_FIELD_Y = "magnetic_field_y" +CONF_MAGNETIC_FIELD_Z = "magnetic_field_z" + +BMM150Data = bmi270_ns.namespace("bmm150").class_("BMM150Data") + +_MAGNETIC_FIELDS = (CONF_MAGNETIC_FIELD_X, CONF_MAGNETIC_FIELD_Y, CONF_MAGNETIC_FIELD_Z) + + +def _parent_schema() -> cv.Schema: + return cv.Schema({cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component)}) + + +def _temperature_sensor_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, + ).extend(_parent_schema()) + + +def _magnetic_field_sensor_schema() -> cv.Schema: + return sensor.sensor_schema( + unit_of_measurement=UNIT_MICROTESLA, + icon=ICON_MAGNET, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ).extend(_parent_schema()) + + +# `type` defaults to temperature so existing configs that omit it keep working unchanged. +CONFIG_SCHEMA = cv.typed_schema( { - cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), - cv.GenerateID(CONF_BMI270_ID): cv.use_id(BMI270Component), - } + CONF_TEMPERATURE: _temperature_sensor_schema(), + **{x: _magnetic_field_sensor_schema() for x in _MAGNETIC_FIELDS}, + }, + default_type=CONF_TEMPERATURE, ) +def _final_validate(config: ConfigType) -> None: + if config[CONF_TYPE] not in _MAGNETIC_FIELDS: + return + full_config = fv.full_config.get() + bmi270_path = full_config.get_path_for_id(config[CONF_BMI270_ID])[:-1] + bmi270_config = full_config.get_config_for_path(bmi270_path) + if bmi270_config.get(CONF_AUX_DEVICE) != "BMM150": + raise cv.Invalid( + "This sensor requires the parent bmi270's 'aux_device' to be set to 'BMM150'", + path=[CONF_TYPE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + 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_BMI270_ID]) + + if sensor_type == CONF_TEMPERATURE: + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) + return + + axis = sensor_type[-1:] data = MockObj("data") value_lambda = await cg.process_lambda( - var.publish_state(data), - [(cg.float_, str(data))], + var.publish_state(getattr(data, axis)), + [(BMM150Data.operator("const").operator("ref"), str(data))], ) - cg.add(parent.add_temperature_listener(value_lambda)) + cg.add(parent.add_magnetometer_listener(value_lambda)) diff --git a/esphome/components/bmp085/__init__.py b/esphome/components/bmp085/__init__.py index e69de29bb2..51355f5bc0 100644 --- a/esphome/components/bmp085/__init__.py +++ b/esphome/components/bmp085/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp085" diff --git a/esphome/components/bmp085/bmp085.cpp b/esphome/components/bmp085/bmp085.cpp index f42875b208..b989ed877b 100644 --- a/esphome/components/bmp085/bmp085.cpp +++ b/esphome/components/bmp085/bmp085.cpp @@ -3,7 +3,10 @@ namespace esphome::bmp085 { -static const char *const TAG = "bmp085.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp085.sensor"); + +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 0; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 1; static const uint8_t BMP085_ADDRESS = 0x77; static const uint8_t BMP085_REGISTER_AC1_H = 0xAA; @@ -16,7 +19,7 @@ void BMP085Component::update() { if (!this->set_mode_(BMP085_CONTROL_MODE_TEMPERATURE)) return; - this->set_timeout("temperature", 5, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 5, [this]() { this->read_temperature_(); }); } void BMP085Component::setup() { uint8_t data[22]; @@ -84,7 +87,7 @@ void BMP085Component::read_temperature_() { return; } - this->set_timeout("pressure", 26, [this]() { this->read_pressure_(); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 26, [this]() { this->read_pressure_(); }); } void BMP085Component::read_pressure_() { uint8_t buffer[3]; 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/__init__.py b/esphome/components/bmp280/__init__.py index e69de29bb2..99130dd4e1 100644 --- a/esphome/components/bmp280/__init__.py +++ b/esphome/components/bmp280/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp280" diff --git a/esphome/components/bmp280_base/__init__.py b/esphome/components/bmp280_base/__init__.py index c0f0ae90bf..68d9145378 100644 --- a/esphome/components/bmp280_base/__init__.py +++ b/esphome/components/bmp280_base/__init__.py @@ -17,6 +17,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@ademuri"] +DOMAIN = "bmp280_base" bmp280_ns = cg.esphome_ns.namespace("bmp280_base") BMP280Oversampling = bmp280_ns.enum("BMP280Oversampling") @@ -75,14 +76,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 34e1d67101..9cb023c688 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -6,7 +6,9 @@ namespace esphome::bmp280_base { -static const char *const TAG = "bmp280.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp280.sensor"); + +static constexpr uint32_t DATA_TIMEOUT_ID = 0; static const uint8_t BMP280_REGISTER_STATUS = 0xF3; static const uint8_t BMP280_REGISTER_CONTROL = 0xF4; @@ -166,7 +168,7 @@ void BMP280Component::update() { meas_time += 2.3f * oversampling_to_time(this->temperature_oversampling_); meas_time += 2.3f * oversampling_to_time(this->pressure_oversampling_) + 0.575f; - this->set_timeout("data", uint32_t(ceilf(meas_time)), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ceilf(meas_time)), [this]() { int32_t t_fine = 0; float temperature = this->read_temperature_(&t_fine); if (std::isnan(temperature)) { diff --git a/esphome/components/bmp280_i2c/__init__.py b/esphome/components/bmp280_i2c/__init__.py index e69de29bb2..38fc9582e5 100644 --- a/esphome/components/bmp280_i2c/__init__.py +++ b/esphome/components/bmp280_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp280_i2c" diff --git a/esphome/components/bmp280_spi/__init__.py b/esphome/components/bmp280_spi/__init__.py index e69de29bb2..2b67436333 100644 --- a/esphome/components/bmp280_spi/__init__.py +++ b/esphome/components/bmp280_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp280_spi" diff --git a/esphome/components/bmp3xx/__init__.py b/esphome/components/bmp3xx/__init__.py index e69de29bb2..076eafa8e7 100644 --- a/esphome/components/bmp3xx/__init__.py +++ b/esphome/components/bmp3xx/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp3xx" diff --git a/esphome/components/bmp3xx_base/__init__.py b/esphome/components/bmp3xx_base/__init__.py index 75e168378e..de9b227aac 100644 --- a/esphome/components/bmp3xx_base/__init__.py +++ b/esphome/components/bmp3xx_base/__init__.py @@ -17,6 +17,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@martgras", "@latonita"] +DOMAIN = "bmp3xx_base" bmp3xx_ns = cg.esphome_ns.namespace("bmp3xx_base") Oversampling = bmp3xx_ns.enum("Oversampling") @@ -80,18 +81,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/bmp3xx_base/bmp3xx_base.cpp b/esphome/components/bmp3xx_base/bmp3xx_base.cpp index aa5e799db1..b10a7a7a95 100644 --- a/esphome/components/bmp3xx_base/bmp3xx_base.cpp +++ b/esphome/components/bmp3xx_base/bmp3xx_base.cpp @@ -13,7 +13,9 @@ namespace esphome::bmp3xx_base { -static const char *const TAG = "bmp3xx.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp3xx.sensor"); + +static constexpr uint32_t DATA_TIMEOUT_ID = 0; static const LogString *chip_type_to_str(uint8_t chip_type) { switch (chip_type) { @@ -170,7 +172,7 @@ void BMP3XXComponent::update() { const uint32_t meas_timeout = uint32_t(ceilf(meas_time)); ESP_LOGVV(TAG, "measurement time %" PRIu32, meas_timeout); - this->set_timeout("data", meas_timeout, [this]() { + this->set_timeout(DATA_TIMEOUT_ID, meas_timeout, [this]() { float temperature = 0.0f; float pressure = 0.0f; if (this->pressure_sensor_ != nullptr) { diff --git a/esphome/components/bmp3xx_i2c/__init__.py b/esphome/components/bmp3xx_i2c/__init__.py index e69de29bb2..87cf0e5261 100644 --- a/esphome/components/bmp3xx_i2c/__init__.py +++ b/esphome/components/bmp3xx_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp3xx_i2c" diff --git a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp index 704d415993..8d8f3122a9 100644 --- a/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp +++ b/esphome/components/bmp3xx_i2c/bmp3xx_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::bmp3xx_i2c { -static const char *const TAG = "bmp3xx_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp3xx_i2c.sensor"); bool BMP3XXI2CComponent::read_byte(uint8_t a_register, uint8_t *data) { return I2CDevice::read_byte(a_register, data); diff --git a/esphome/components/bmp3xx_spi/__init__.py b/esphome/components/bmp3xx_spi/__init__.py index e69de29bb2..6e04124ccb 100644 --- a/esphome/components/bmp3xx_spi/__init__.py +++ b/esphome/components/bmp3xx_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp3xx_spi" diff --git a/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp b/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp index 5657626c2d..741f656c07 100644 --- a/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp +++ b/esphome/components/bmp3xx_spi/bmp3xx_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::bmp3xx_spi { -static const char *const TAG = "bmp3xx_spi.sensor"; +ESPHOME_LOG_TAG(TAG, "bmp3xx_spi.sensor"); uint8_t set_bit(uint8_t num, int position) { int mask = 1 << position; diff --git a/esphome/components/bmp581/__init__.py b/esphome/components/bmp581/__init__.py index e69de29bb2..ad0da1d8f7 100644 --- a/esphome/components/bmp581/__init__.py +++ b/esphome/components/bmp581/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp581" diff --git a/esphome/components/bmp581_base/__init__.py b/esphome/components/bmp581_base/__init__.py index 1c2c5c37d4..18c18a2e9b 100644 --- a/esphome/components/bmp581_base/__init__.py +++ b/esphome/components/bmp581_base/__init__.py @@ -19,6 +19,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kahrendt", "@danielkent-net"] +DOMAIN = "bmp581_base" bmp581_ns = cg.esphome_ns.namespace("bmp581_base") @@ -137,9 +138,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 +150,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/bmp581_base/bmp581_base.cpp b/esphome/components/bmp581_base/bmp581_base.cpp index 7a627eee03..29f67fadc5 100644 --- a/esphome/components/bmp581_base/bmp581_base.cpp +++ b/esphome/components/bmp581_base/bmp581_base.cpp @@ -17,7 +17,9 @@ namespace esphome::bmp581_base { -static const char *const TAG = "bmp581"; +ESPHOME_LOG_TAG(TAG, "bmp581"); + +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; // Oversampling strings indexed by Oversampling enum (0-7): NONE, X2, X4, X8, X16, X32, X64, X128 PROGMEM_STRING_TABLE(OversamplingStrings, "None", "2x", "4x", "8x", "16x", "32x", "64x", "128x", ""); @@ -272,7 +274,7 @@ void BMP581Component::update() { ESP_LOGVV(TAG, "Measurement should take %d ms", this->conversion_time_); - this->set_timeout("measurement", this->conversion_time_, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, this->conversion_time_, [this]() { float temperature = 0.0; float pressure = 0.0; diff --git a/esphome/components/bmp581_i2c/__init__.py b/esphome/components/bmp581_i2c/__init__.py index e69de29bb2..a5c70d3d42 100644 --- a/esphome/components/bmp581_i2c/__init__.py +++ b/esphome/components/bmp581_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp581_i2c" diff --git a/esphome/components/bmp581_spi/__init__.py b/esphome/components/bmp581_spi/__init__.py index e69de29bb2..d8f525adce 100644 --- a/esphome/components/bmp581_spi/__init__.py +++ b/esphome/components/bmp581_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "bmp581_spi" diff --git a/esphome/components/bmp581_spi/bmp581_spi.cpp b/esphome/components/bmp581_spi/bmp581_spi.cpp index 01435880f0..129128026c 100644 --- a/esphome/components/bmp581_spi/bmp581_spi.cpp +++ b/esphome/components/bmp581_spi/bmp581_spi.cpp @@ -7,7 +7,7 @@ namespace esphome::bmp581_spi { -static const char *const TAG = "bmp581_spi"; +ESPHOME_LOG_TAG(TAG, "bmp581_spi"); // OR (|) register with BMP_SPI_READ for read inline constexpr uint8_t BMP_SPI_READ = 0x80; diff --git a/esphome/components/bp1658cj/__init__.py b/esphome/components/bp1658cj/__init__.py index b45272d2ea..76b3d427a4 100644 --- a/esphome/components/bp1658cj/__init__.py +++ b/esphome/components/bp1658cj/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_CLOCK_PIN, CONF_DATA_PIN, CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Cossid"] +DOMAIN = "bp1658cj" MULTI_CONF = True CONF_MAX_POWER_COLOR_CHANNELS = "max_power_color_channels" diff --git a/esphome/components/bp1658cj/bp1658cj.cpp b/esphome/components/bp1658cj/bp1658cj.cpp index 95a6b3be45..c7dc61c6f0 100644 --- a/esphome/components/bp1658cj/bp1658cj.cpp +++ b/esphome/components/bp1658cj/bp1658cj.cpp @@ -3,7 +3,7 @@ namespace esphome::bp1658cj { -static const char *const TAG = "bp1658cj"; +ESPHOME_LOG_TAG(TAG, "bp1658cj"); static const uint8_t BP1658CJ_MODEL_ID = 0x80; static const uint8_t BP1658CJ_ADDR_STANDBY = 0x0; diff --git a/esphome/components/bp5758d/__init__.py b/esphome/components/bp5758d/__init__.py index fa4e8a231b..bd6b6ca110 100644 --- a/esphome/components/bp5758d/__init__.py +++ b/esphome/components/bp5758d/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_CLOCK_PIN, CONF_DATA_PIN, CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Cossid"] +DOMAIN = "bp5758d" MULTI_CONF = True AUTO_LOAD = ["output"] diff --git a/esphome/components/bp5758d/bp5758d.cpp b/esphome/components/bp5758d/bp5758d.cpp index 3cd841b2cd..5789acdcb9 100644 --- a/esphome/components/bp5758d/bp5758d.cpp +++ b/esphome/components/bp5758d/bp5758d.cpp @@ -3,7 +3,7 @@ namespace esphome::bp5758d { -static const char *const TAG = "bp5758d"; +ESPHOME_LOG_TAG(TAG, "bp5758d"); static const uint8_t BP5758D_MODEL_ID = 0b10000000; static const uint8_t BP5758D_ADDR_STANDBY = 0b00000000; diff --git a/esphome/components/bq25186/__init__.py b/esphome/components/bq25186/__init__.py new file mode 100644 index 0000000000..e57860786f --- /dev/null +++ b/esphome/components/bq25186/__init__.py @@ -0,0 +1,314 @@ +from collections.abc import Callable +from typing import Any + +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.core import CORE, ID, Lambda +from esphome.types import ConfigType, TemplateArgsType + +CODEOWNERS = ["@Rapsssito"] +DEPENDENCIES = ["i2c"] +DOMAIN = "bq25186" +MULTI_CONF = True + +CONF_CONFIGURE_ON_BOOT = "configure_on_boot" +CONF_BATTERY_REGULATION_VOLTAGE = "battery_regulation_voltage" +CONF_FAST_CHARGE_CURRENT = "fast_charge_current" +CONF_CHARGE_ENABLED = "charge_enabled" +CONF_FLASH_CHARGING_MODE = "flash_charging_mode" +CONF_PRECHARGE_IS_ITERM = "precharge_is_iterm" +CONF_TERMINATION_PERCENT = "termination_percent" +CONF_VINDPM_MODE = "vindpm_mode" +CONF_THERMAL_REGULATION = "thermal_regulation" +CONF_BATTERY_OCP_LIMIT = "battery_ocp_limit" +CONF_BATTERY_UVLO = "battery_uvlo" +CONF_MASK_CHARGE_STATUS_INTERRUPT = "mask_charge_status_interrupt" +CONF_MASK_ILIM_INTERRUPT = "mask_ilim_interrupt" +CONF_MASK_VINDPM_INTERRUPT = "mask_vindpm_interrupt" +CONF_TS_AUTO_FUNCTION = "ts_auto_function" +CONF_VLOWV = "vlowv" +CONF_RECHARGE_THRESHOLD = "recharge_threshold" +CONF_DOUBLE_TIMER_DURING_DPM = "double_timer_during_dpm" +CONF_SAFETY_TIMER = "safety_timer" +CONF_LONG_PRESS_DURATION = "long_press_duration" +CONF_HW_RESET_REQUIRES_VIN = "hw_reset_requires_vin" +CONF_AUTOWAKE = "autowake" +CONF_INPUT_CURRENT_LIMIT = "input_current_limit" +CONF_PUSH_BUTTON_LONG_PRESS_ACTION = "push_button_long_press_action" +CONF_WAKE1_TIMER = "wake1_timer" +CONF_WAKE2_TIMER = "wake2_timer" +CONF_ENABLE_PUSH_BUTTON = "enable_push_button" +CONF_SYSTEM_REGULATION = "system_regulation" +CONF_SYS_MODE = "sys_mode" +CONF_DISABLE_VDPPM = "disable_vdppm" +CONF_TS_HOT = "ts_hot" +CONF_TS_COLD = "ts_cold" +CONF_TS_WARM_DISABLE = "ts_warm_disable" +CONF_TS_COOL_DISABLE = "ts_cool_disable" +CONF_TS_ICHG = "ts_ichg" +CONF_TS_VRCG = "ts_vrcg" +CONF_MASK_TS_INTERRUPT = "mask_ts_interrupt" +CONF_MASK_TREG_INTERRUPT = "mask_treg_interrupt" +CONF_MASK_BAT_INTERRUPT = "mask_bat_interrupt" +CONF_MASK_PG_INTERRUPT = "mask_pg_interrupt" + +bq25186_ns = cg.esphome_ns.namespace("bq25186") +BQ25186Component = bq25186_ns.class_("BQ25186Component", cg.Component, i2c.I2CDevice) + +REG_VBAT_CTRL = 0x03 +REG_ICHG_CTRL = 0x04 +REG_CHARGECTRL0 = 0x05 +REG_CHARGECTRL1 = 0x06 +REG_IC_CTRL = 0x07 +REG_TMR_ILIM = 0x08 +REG_SHIP_RST = 0x09 +REG_SYS_REG = 0x0A +REG_TS_CONTROL = 0x0B +REG_MASK_ID = 0x0C +REGISTER_COUNT = REG_MASK_ID - REG_VBAT_CTRL + 1 +WATCHDOG_DISABLED = 0x03 + +TERMINATION_PERCENT_OPTIONS = {"disabled": 0, "5%": 1, "10%": 2, "20%": 3} +VINDPM_MODE_OPTIONS = {"battery_tracking": 0, "4.5v": 1, "4.7v": 2, "disabled": 3} +THERM_REGULATION_OPTIONS = {"100c": 0, "80c": 1, "60c": 2, "disabled": 3} +BATTERY_OCP_LIMIT_OPTIONS = {"500ma": 0, "1000ma": 1, "1500ma": 2, "3000ma": 3} +BATTERY_UVLO_OPTIONS = { + "3.0v": 0, + "2.8v": 3, + "2.6v": 4, + "2.4v": 5, + "2.2v": 6, + "2.0v": 7, +} +VLOWV_OPTIONS = {"3.0v": 0, "2.8v": 1} +RECHARGE_THRESHOLD_OPTIONS = {"100mv": 0, "200mv": 1} +SAFETY_TIMER_OPTIONS = {"3h": 0, "6h": 1, "12h": 2, "disabled": 3} +LONG_PRESS_DURATION_OPTIONS = {"5s": 0, "10s": 1, "15s": 2, "20s": 3} +AUTOWAKE_OPTIONS = {"0.5s": 0, "1s": 1, "2s": 2, "4s": 3} +INPUT_CURRENT_LIMIT_OPTIONS = { + "50ma": 0, + "100ma": 1, + "200ma": 2, + "300ma": 3, + "400ma": 4, + "500ma": 5, + "665ma": 6, + "1050ma": 7, +} +PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS = { + "none": 0, + "hardware_reset": 1, + "ship_mode": 2, + "shutdown_mode": 3, +} +WAKE1_TIMER_OPTIONS = {"300ms": 0, "1s": 1} +WAKE2_TIMER_OPTIONS = {"2s": 0, "3s": 1} +SYSTEM_REGULATION_OPTIONS = { + "battery_tracking": 0, + "4.4v": 1, + "4.5v": 2, + "4.6v": 3, + "4.7v": 4, + "4.8v": 5, + "4.9v": 6, + "pass_through": 7, +} +SYS_MODE_OPTIONS = { + "normal": 0, + "battery_only": 1, + "floating_off": 2, + "pulldown_off": 3, +} +TS_HOT_OPTIONS = {"60c": 0, "65c": 1, "50c": 2, "45c": 3} +TS_COLD_OPTIONS = {"0c": 0, "3c": 1, "5c": 2, "-3c": 3} +TS_ICHG_OPTIONS = {"0.5x": 0, "0.2x": 1} +TS_VRCG_OPTIONS = {"-100mv": 0, "-200mv": 1} + + +def _on_voltage_step(volts: float) -> float: + millivolts = volts * 1000 + if abs(millivolts - round(millivolts / 10) * 10) > 1e-6: + raise cv.Invalid("battery_regulation_voltage must be a multiple of 10 mV") + return volts + + +def _on_current_step(amps: float) -> float: + milliamps = amps * 1000 + step = 1 if milliamps < 36 else 10 + if abs(milliamps - round(milliamps / step) * step) > 1e-6 or 35 < milliamps < 40: + raise cv.Invalid( + "fast_charge_current must be a whole number of mA up to 35mA, then a multiple of 10 mA" + ) + return amps + + +# (config key, register, bit shift, validator or enum options, default). +# VBAT_CTRL bit 7 (pg_mode) and SYS_REG bit 4 (pg_gpo_level) stay 0; they are +# driven at runtime, not from configuration. +FIELDS: tuple[tuple[str, int, int, Any, Any], ...] = ( + ( + CONF_BATTERY_REGULATION_VOLTAGE, + REG_VBAT_CTRL, + 0, + cv.All(cv.voltage, cv.Range(min=3.5, max=4.65), _on_voltage_step), + "4.2V", + ), + (CONF_CHARGE_ENABLED, REG_ICHG_CTRL, 7, cv.boolean, True), + ( + CONF_FAST_CHARGE_CURRENT, + REG_ICHG_CTRL, + 0, + cv.All(cv.current, cv.Range(min=0.005, max=1.0), _on_current_step), + "10mA", + ), + (CONF_FLASH_CHARGING_MODE, REG_CHARGECTRL0, 7, cv.boolean, False), + (CONF_PRECHARGE_IS_ITERM, REG_CHARGECTRL0, 6, cv.boolean, False), + (CONF_TERMINATION_PERCENT, REG_CHARGECTRL0, 4, TERMINATION_PERCENT_OPTIONS, "10%"), + (CONF_VINDPM_MODE, REG_CHARGECTRL0, 2, VINDPM_MODE_OPTIONS, "4.5v"), + (CONF_THERMAL_REGULATION, REG_CHARGECTRL0, 0, THERM_REGULATION_OPTIONS, "100c"), + (CONF_BATTERY_OCP_LIMIT, REG_CHARGECTRL1, 6, BATTERY_OCP_LIMIT_OPTIONS, "3000ma"), + (CONF_BATTERY_UVLO, REG_CHARGECTRL1, 3, BATTERY_UVLO_OPTIONS, "3.0v"), + (CONF_MASK_CHARGE_STATUS_INTERRUPT, REG_CHARGECTRL1, 2, cv.boolean, True), + (CONF_MASK_ILIM_INTERRUPT, REG_CHARGECTRL1, 1, cv.boolean, True), + (CONF_MASK_VINDPM_INTERRUPT, REG_CHARGECTRL1, 0, cv.boolean, False), + (CONF_TS_AUTO_FUNCTION, REG_IC_CTRL, 7, cv.boolean, True), + (CONF_VLOWV, REG_IC_CTRL, 6, VLOWV_OPTIONS, "3.0v"), + (CONF_RECHARGE_THRESHOLD, REG_IC_CTRL, 5, RECHARGE_THRESHOLD_OPTIONS, "100mv"), + (CONF_DOUBLE_TIMER_DURING_DPM, REG_IC_CTRL, 4, cv.boolean, False), + (CONF_SAFETY_TIMER, REG_IC_CTRL, 2, SAFETY_TIMER_OPTIONS, "6h"), + (CONF_LONG_PRESS_DURATION, REG_TMR_ILIM, 6, LONG_PRESS_DURATION_OPTIONS, "10s"), + (CONF_HW_RESET_REQUIRES_VIN, REG_TMR_ILIM, 5, cv.boolean, False), + (CONF_AUTOWAKE, REG_TMR_ILIM, 3, AUTOWAKE_OPTIONS, "1s"), + (CONF_INPUT_CURRENT_LIMIT, REG_TMR_ILIM, 0, INPUT_CURRENT_LIMIT_OPTIONS, "500ma"), + ( + CONF_PUSH_BUTTON_LONG_PRESS_ACTION, + REG_SHIP_RST, + 3, + PUSH_BUTTON_LONG_PRESS_ACTION_OPTIONS, + "ship_mode", + ), + (CONF_WAKE1_TIMER, REG_SHIP_RST, 2, WAKE1_TIMER_OPTIONS, "300ms"), + (CONF_WAKE2_TIMER, REG_SHIP_RST, 1, WAKE2_TIMER_OPTIONS, "2s"), + (CONF_ENABLE_PUSH_BUTTON, REG_SHIP_RST, 0, cv.boolean, True), + (CONF_SYSTEM_REGULATION, REG_SYS_REG, 5, SYSTEM_REGULATION_OPTIONS, "4.5v"), + (CONF_SYS_MODE, REG_SYS_REG, 2, SYS_MODE_OPTIONS, "normal"), + (CONF_DISABLE_VDPPM, REG_SYS_REG, 0, cv.boolean, False), + (CONF_TS_HOT, REG_TS_CONTROL, 6, TS_HOT_OPTIONS, "60c"), + (CONF_TS_COLD, REG_TS_CONTROL, 4, TS_COLD_OPTIONS, "0c"), + (CONF_TS_WARM_DISABLE, REG_TS_CONTROL, 3, cv.boolean, False), + (CONF_TS_COOL_DISABLE, REG_TS_CONTROL, 2, cv.boolean, False), + (CONF_TS_ICHG, REG_TS_CONTROL, 1, TS_ICHG_OPTIONS, "0.5x"), + (CONF_TS_VRCG, REG_TS_CONTROL, 0, TS_VRCG_OPTIONS, "-100mv"), + (CONF_MASK_TS_INTERRUPT, REG_MASK_ID, 7, cv.boolean, False), + (CONF_MASK_TREG_INTERRUPT, REG_MASK_ID, 6, cv.boolean, True), + (CONF_MASK_BAT_INTERRUPT, REG_MASK_ID, 5, cv.boolean, False), + (CONF_MASK_PG_INTERRUPT, REG_MASK_ID, 4, cv.boolean, False), +) + + +def _battery_regulation_code(volts: float) -> int: + """VBAT_CTRL[6:0]: 3.5 V plus 10 mV per step.""" + return (round(volts * 1000) - 3500) // 10 + + +def _fast_charge_code(amps: float) -> int: + """ICHG_CTRL[6:0]: 1 mA steps from 5 mA to 35 mA, then 10 mA steps from 40 mA.""" + milliamps = round(amps * 1000) + if milliamps <= 35: + return milliamps - 5 + return milliamps // 10 + 27 + + +_ENCODERS: dict[str, Callable[[Any], int]] = { + CONF_BATTERY_REGULATION_VOLTAGE: _battery_regulation_code, + CONF_FAST_CHARGE_CURRENT: _fast_charge_code, + CONF_CHARGE_ENABLED: lambda enabled: not enabled, +} + + +def pack_registers(config: ConfigType) -> list[int]: + """Pack the validated field values into the VBAT_CTRL..MASK_ID register bytes.""" + regs = [0] * REGISTER_COUNT + # Watchdog disabled (IC_CTRL[1:0] = 3): nothing talks to the chip after boot yet, and a running + # watchdog restores the power-on defaults after 160 s. Part 2 adds the option with the polling. + regs[REG_IC_CTRL - REG_VBAT_CTRL] = WATCHDOG_DISABLED + for key, register, shift, validator, _ in FIELDS: + value = config[key] + if isinstance(validator, dict): + value = validator[value] + elif (encode := _ENCODERS.get(key)) is not None: + value = encode(value) + regs[register - REG_VBAT_CTRL] |= int(value) << shift + return regs + + +def _field_schema(with_defaults: bool) -> cv.Schema: + schema = {} + for key, _, _, validator, default in FIELDS: + if isinstance(validator, dict): + validator = cv.enum(validator, lower=True) + optional = ( + cv.Optional(key, default=default) if with_defaults else cv.Optional(key) + ) + schema[optional] = validator + return cv.Schema(schema) + + +BQ25186_CONFIG_SCHEMA = _field_schema(with_defaults=True) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ25186Component), + cv.Optional(CONF_CONFIGURE_ON_BOOT, default=True): cv.boolean, + } + ) + .extend(BQ25186_CONFIG_SCHEMA) + .extend(i2c.i2c_device_schema(0x6A)) +) + +CONFIGURE_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(BQ25186Component), + } + ).extend(_field_schema(with_defaults=False)) +) + + +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) + + if config[CONF_CONFIGURE_ON_BOOT]: + cg.add(var.set_setup_registers(cg.ArrayInitializer(*pack_registers(config)))) + + +@automation.register_action( + "bq25186.apply_configuration", + automation.LambdaAction, + CONFIGURE_ACTION_SCHEMA, + synchronous=True, +) +async def bq25186_configure_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.MockObj: + parent = await cg.get_variable(config[CONF_ID]) + + # Options the action leaves out fall back to the component's configuration + path = CORE.config.get_path_for_id(config[CONF_ID])[:-1] + parent_config = CORE.config.get_config_for_path(path) + regs = pack_registers({**parent_config, **config}) + + text = str(cg.statement(parent.apply_config(cg.ArrayInitializer(*regs)))) + lambda_ = await cg.process_lambda(Lambda(text), args, return_type=cg.void) + return automation.new_lambda_pvariable( + action_id, lambda_, automation.StatelessLambdaAction, template_arg + ) diff --git a/esphome/components/bq25186/bq25186.cpp b/esphome/components/bq25186/bq25186.cpp new file mode 100644 index 0000000000..e660ec63d1 --- /dev/null +++ b/esphome/components/bq25186/bq25186.cpp @@ -0,0 +1,79 @@ +#include "bq25186.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::bq25186 { + +ESPHOME_LOG_TAG(TAG, "bq25186"); + +// Bits the configuration owns in each register; the rest are runtime state (VBAT_CTRL pg_mode, +// SYS_REG GPO level), reset triggers (SHIP_RST) or the device id (MASK_ID) and are kept as read +static const uint8_t BQ25186_CONFIG_MASKS[BQ25186_CONFIG_REGISTER_COUNT] PROGMEM = { + 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x1F, 0xEF, 0xFF, 0xF0, +}; + +bool BQ25186Component::write_register_(uint8_t reg, uint8_t value) { + if (!this->write_byte(reg, value)) { + ESP_LOGW(TAG, "Failed to write register 0x%02X", reg); + return false; + } + return true; +} + +bool BQ25186Component::update_register_(uint8_t reg, uint8_t mask, uint8_t value) { + uint8_t reg_value; + if (!this->read_byte(reg, ®_value)) { + ESP_LOGW(TAG, "Failed to read register 0x%02X", reg); + return false; + } + reg_value = (reg_value & ~mask) | (value & mask); + return this->write_register_(reg, reg_value); +} + +bool BQ25186Component::apply_config(const BQ25186ConfigRegisters ®s) { + // Setup rejects an unknown chip; the action must not program it before that check or after it failed + if (!this->is_ready() || this->is_failed()) { + ESP_LOGW(TAG, "Not applying configuration, setup has not succeeded"); + return false; + } + for (size_t i = 0; i < regs.size(); i++) { + const uint8_t reg = BQ25186_REG_VBAT_CTRL + i; + const uint8_t mask = progmem_read_byte(&BQ25186_CONFIG_MASKS[i]); + const bool ok = mask == 0xFF ? this->write_register_(reg, regs[i]) : this->update_register_(reg, mask, regs[i]); + if (!ok) { + this->status_set_warning(LOG_STR("Configuration partly applied")); + return false; + } + } + this->status_clear_warning(); + return true; +} + +void BQ25186Component::setup() { + uint8_t mask_id; + if (!this->read_byte(BQ25186_REG_MASK_ID, &mask_id)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Another charger at this address could lay its registers out differently, so do not write into it + const uint8_t device_id = mask_id & 0x0F; + if (device_id != 0x01) { + ESP_LOGE(TAG, "Unexpected device id 0x%X (expected 0x1)", device_id); + this->mark_failed(LOG_STR("Not a BQ25186")); + return; + } + + // A failed write only sets the warning status: the chip has identified itself, so a later + // apply_configuration may retry + if (this->configure_on_boot_) + this->apply_config(this->setup_regs_); +} + +void BQ25186Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ25186:"); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::bq25186 diff --git a/esphome/components/bq25186/bq25186.h b/esphome/components/bq25186/bq25186.h new file mode 100644 index 0000000000..a692087147 --- /dev/null +++ b/esphome/components/bq25186/bq25186.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" + +#include + +namespace esphome::bq25186 { + +static constexpr uint8_t BQ25186_REG_VBAT_CTRL = 0x03; +static constexpr uint8_t BQ25186_REG_ICHG_CTRL = 0x04; +static constexpr uint8_t BQ25186_REG_CHARGECTRL0 = 0x05; +static constexpr uint8_t BQ25186_REG_CHARGECTRL1 = 0x06; +static constexpr uint8_t BQ25186_REG_IC_CTRL = 0x07; +static constexpr uint8_t BQ25186_REG_TMR_ILIM = 0x08; +static constexpr uint8_t BQ25186_REG_SHIP_RST = 0x09; +static constexpr uint8_t BQ25186_REG_SYS_REG = 0x0A; +static constexpr uint8_t BQ25186_REG_TS_CONTROL = 0x0B; +static constexpr uint8_t BQ25186_REG_MASK_ID = 0x0C; +static constexpr size_t BQ25186_CONFIG_REGISTER_COUNT = BQ25186_REG_MASK_ID - BQ25186_REG_VBAT_CTRL + 1; + +// Register values for VBAT_CTRL..MASK_ID, packed by code generation +using BQ25186ConfigRegisters = std::array; + +class BQ25186Component : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + void set_setup_registers(const BQ25186ConfigRegisters ®s) { + this->setup_regs_ = regs; + this->configure_on_boot_ = true; + } + bool apply_config(const BQ25186ConfigRegisters ®s); + + protected: + bool write_register_(uint8_t reg, uint8_t value); + bool update_register_(uint8_t reg, uint8_t mask, uint8_t value); + + BQ25186ConfigRegisters setup_regs_{}; + bool configure_on_boot_{false}; +}; + +} // namespace esphome::bq25186 diff --git a/esphome/components/bq27220/__init__.py b/esphome/components/bq27220/__init__.py new file mode 100644 index 0000000000..2a5d7bc1d7 --- /dev/null +++ b/esphome/components/bq27220/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@rggammon"] +DOMAIN = "bq27220" diff --git a/esphome/components/bq27220/bq27220.cpp b/esphome/components/bq27220/bq27220.cpp new file mode 100644 index 0000000000..d2f7961e02 --- /dev/null +++ b/esphome/components/bq27220/bq27220.cpp @@ -0,0 +1,93 @@ +#include "bq27220.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::bq27220 { + +ESPHOME_LOG_TAG(TAG, "bq27220"); + +// Standard command registers (16-bit, little-endian), SLUUBD4A +static constexpr uint8_t REG_CONTROL = 0x00; +static constexpr uint8_t REG_TEMPERATURE = 0x06; // 0.1 K +static constexpr uint8_t REG_VOLTAGE = 0x08; // mV +static constexpr uint8_t REG_CURRENT = 0x0C; // mA, signed +static constexpr uint8_t REG_REMAINING_CAPACITY = 0x10; // mAh +static constexpr uint8_t REG_FULL_CHARGE_CAPACITY = 0x12; // mAh +static constexpr uint8_t REG_TIME_TO_EMPTY = 0x16; // min, 0xFFFF when not discharging +static constexpr uint8_t REG_STATE_OF_CHARGE = 0x2C; // % +static constexpr uint8_t REG_STATE_OF_HEALTH = 0x2E; // % +// The DEVICE_NUMBER subcommand (0x0001, SLUUBD4A §2.2.2) answers through the MAC data buffer +static constexpr uint8_t REG_MAC_DATA = 0x40; +static constexpr uint16_t DEVICE_NUMBER = 0x0220; +static constexpr uint16_t TIME_TO_EMPTY_NOT_APPLICABLE = 0xFFFF; + +bool BQ27220Component::read_word_(uint8_t reg, uint16_t &value) { + uint8_t data[2]; + if (this->read_register(reg, data, 2) != i2c::ERROR_OK) { + return false; + } + value = encode_uint16(data[1], data[0]); + return true; +} + +void BQ27220Component::setup() { + const uint8_t device_number_cmd[2] = {0x01, 0x00}; // DEVICE_NUMBER subcommand, little-endian + if (this->write_register(REG_CONTROL, device_number_cmd, 2) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to communicate with BQ27220")); + return; + } + delay(2); // SLUUBD4A §5.3: the subcommand result needs a moment before it can be read + uint16_t device_number = 0; + if (!this->read_word_(REG_MAC_DATA, device_number)) { + this->mark_failed(LOG_STR("Failed to read device number from BQ27220")); + return; + } + if (device_number != DEVICE_NUMBER) { + ESP_LOGE(TAG, "Unexpected device number 0x%04X (expected 0x%04X)", device_number, DEVICE_NUMBER); + this->mark_failed(); + } +} + +void BQ27220Component::update() { + bool ok = true; + this->publish_(this->voltage_sensor_, REG_VOLTAGE, ok, [](uint16_t raw) { return raw * 0.001f; }); + this->publish_(this->current_sensor_, REG_CURRENT, ok, + [](uint16_t raw) { return static_cast(raw) * 0.001f; }); + this->publish_(this->battery_level_sensor_, REG_STATE_OF_CHARGE, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->temperature_sensor_, REG_TEMPERATURE, ok, [](uint16_t raw) { return raw * 0.1f - 273.15f; }); + this->publish_(this->remaining_capacity_sensor_, REG_REMAINING_CAPACITY, ok, [](uint16_t raw) { return float(raw); }); + this->publish_(this->full_charge_capacity_sensor_, REG_FULL_CHARGE_CAPACITY, ok, + [](uint16_t raw) { return float(raw); }); + this->publish_(this->time_to_empty_sensor_, REG_TIME_TO_EMPTY, ok, + [](uint16_t raw) { return raw == TIME_TO_EMPTY_NOT_APPLICABLE ? NAN : float(raw); }); + this->publish_(this->state_of_health_sensor_, REG_STATE_OF_HEALTH, ok, [](uint16_t raw) { return float(raw); }); + + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Failed to read one or more registers from BQ27220")); + } +} + +void BQ27220Component::dump_config() { + ESP_LOGCONFIG(TAG, "BQ27220 Battery Fuel Gauge:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Remaining Capacity", this->remaining_capacity_sensor_); + LOG_SENSOR(" ", "Full Charge Capacity", this->full_charge_capacity_sensor_); + LOG_SENSOR(" ", "Time To Empty", this->time_to_empty_sensor_); + LOG_SENSOR(" ", "State Of Health", this->state_of_health_sensor_); +} + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/bq27220.h b/esphome/components/bq27220/bq27220.h new file mode 100644 index 0000000000..d88b76a20f --- /dev/null +++ b/esphome/components/bq27220/bq27220.h @@ -0,0 +1,44 @@ +#pragma once + +// Datasheet: https://www.ti.com/lit/ds/symlink/bq27220.pdf +// Technical Reference Manual: https://www.ti.com/lit/ug/sluubd4a/sluubd4a.pdf + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::bq27220 { + +class BQ27220Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(voltage) + SUB_SENSOR(current) + SUB_SENSOR(battery_level) + SUB_SENSOR(temperature) + SUB_SENSOR(remaining_capacity) + SUB_SENSOR(full_charge_capacity) + SUB_SENSOR(time_to_empty) + SUB_SENSOR(state_of_health) + + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool read_word_(uint8_t reg, uint16_t &value); + /// Read a standard command register and publish it through the sensor, if one is configured. + /// A failed read publishes NAN and clears `ok`. + template void publish_(sensor::Sensor *sensor, uint8_t reg, bool &ok, F convert) { + if (sensor == nullptr) + return; + uint16_t raw; + if (!this->read_word_(reg, raw)) { + ok = false; + sensor->publish_state(NAN); + return; + } + sensor->publish_state(convert(raw)); + } +}; + +} // namespace esphome::bq27220 diff --git a/esphome/components/bq27220/sensor.py b/esphome/components/bq27220/sensor.py new file mode 100644 index 0000000000..a3fcec2243 --- /dev/null +++ b/esphome/components/bq27220/sensor.py @@ -0,0 +1,123 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +from esphome.components.const import UNIT_MILLIAMPERE_HOUR +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_CURRENT, + CONF_ID, + CONF_TEMPERATURE, + CONF_VOLTAGE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_DURATION, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_BATTERY, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_MINUTE, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +bq27220_ns = cg.esphome_ns.namespace("bq27220") +BQ27220Component = bq27220_ns.class_( + "BQ27220Component", cg.PollingComponent, i2c.I2CDevice +) + +CONF_REMAINING_CAPACITY = "remaining_capacity" +CONF_FULL_CHARGE_CAPACITY = "full_charge_capacity" +CONF_TIME_TO_EMPTY = "time_to_empty" +CONF_STATE_OF_HEALTH = "state_of_health" + +# Other SLUUBD4A standard commands are intentionally not exposed to keep the +# component focused; they can be added later if there is demand: TimeToFull +# (0x18), CycleCount (0x2A), BatteryStatus (0x0A), AveragePower (0x24), +# DesignCapacity (0x3C). + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(BQ27220Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + 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, + ), + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_REMAINING_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_FULL_CHARGE_CAPACITY): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLIAMPERE_HOUR, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_TIME_TO_EMPTY): sensor.sensor_schema( + unit_of_measurement=UNIT_MINUTE, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_STATE_OF_HEALTH): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_BATTERY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x55)) +) + + +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_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_CURRENT, var.set_current_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_TEMPERATURE, var.set_temperature_sensor) + await sensors(CONF_REMAINING_CAPACITY, var.set_remaining_capacity_sensor) + await sensors(CONF_FULL_CHARGE_CAPACITY, var.set_full_charge_capacity_sensor) + await sensors(CONF_TIME_TO_EMPTY, var.set_time_to_empty_sensor) + await sensors(CONF_STATE_OF_HEALTH, var.set_state_of_health_sensor) diff --git a/esphome/components/bthome_mithermometer/__init__.py b/esphome/components/bthome_mithermometer/__init__.py index ed0cbaa9e1..fe78162c65 100644 --- a/esphome/components/bthome_mithermometer/__init__.py +++ b/esphome/components/bthome_mithermometer/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@nagyrobi"] AUTO_LOAD = ["ble_device_base"] +DOMAIN = "bthome_mithermometer" bthome_mithermometer_ns = cg.esphome_ns.namespace("bthome_mithermometer") diff --git a/esphome/components/bthome_mithermometer/bthome_ble.cpp b/esphome/components/bthome_mithermometer/bthome_ble.cpp index 1ebabea0a3..c055c43041 100644 --- a/esphome/components/bthome_mithermometer/bthome_ble.cpp +++ b/esphome/components/bthome_mithermometer/bthome_ble.cpp @@ -26,7 +26,7 @@ namespace esphome::bthome_mithermometer { -static const char *const TAG = "bthome_mithermometer"; +ESPHOME_LOG_TAG(TAG, "bthome_mithermometer"); static constexpr size_t BTHOME_BINDKEY_SIZE = 16; static constexpr size_t BTHOME_NONCE_SIZE = 13; static constexpr size_t BTHOME_MIC_SIZE = 4; @@ -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..f27475bdca 100644 --- a/esphome/components/button/__init__.py +++ b/esphome/components/button/__init__.py @@ -18,15 +18,17 @@ 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"] +DOMAIN = "button" IS_PLATFORM_COMPONENT = True DEVICE_CLASSES = [ @@ -40,8 +42,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 +116,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 +130,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/button/button.cpp b/esphome/components/button/button.cpp index 2a2a645132..e85ce60df6 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -3,7 +3,7 @@ namespace esphome::button { -static const char *const TAG = "button"; +ESPHOME_LOG_TAG(TAG, "button"); // Function implementation of LOG_BUTTON macro to reduce code size void log_button(const char *tag, const char *prefix, const char *type, Button *obj) { diff --git a/esphome/components/bytebuffer/__init__.py b/esphome/components/bytebuffer/__init__.py index 3c7c695118..df0c76338c 100644 --- a/esphome/components/bytebuffer/__init__.py +++ b/esphome/components/bytebuffer/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "bytebuffer" # Allows bytebuffer to be configured in yaml, to allow use of the C++ api. diff --git a/esphome/components/camera/__init__.py b/esphome/components/camera/__init__.py index a19f7707af..0cb916ac49 100644 --- a/esphome/components/camera/__init__.py +++ b/esphome/components/camera/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@DT-art1", "@bdraco"] +DOMAIN = "camera" diff --git a/esphome/components/camera_encoder/__init__.py b/esphome/components/camera_encoder/__init__.py index 344248fcf3..f63c316126 100644 --- a/esphome/components/camera_encoder/__init__.py +++ b/esphome/components/camera_encoder/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TYPE from esphome.types import ConfigType CODEOWNERS = ["@DT-art1"] +DOMAIN = "camera_encoder" AUTO_LOAD = ["camera"] diff --git a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp index 55a3f0b96c..84d5a9117d 100644 --- a/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp +++ b/esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp @@ -6,7 +6,7 @@ namespace esphome::camera_encoder { -static const char *const TAG = "camera_encoder"; +ESPHOME_LOG_TAG(TAG, "camera_encoder"); ESP32CameraJPEGEncoder::ESP32CameraJPEGEncoder(uint8_t quality, camera::EncoderBuffer *output) { this->quality_ = quality; diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index b7de235dd1..56733870dc 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -1,15 +1,19 @@ +import logging import re from typing import Any from esphome import automation import esphome.codegen as cg import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_TRIGGER_ID +from esphome.const import CONF_DATA, CONF_ID, CONF_PLATFORM, CONF_TRIGGER_ID from esphome.core import CORE, ID from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + CODEOWNERS = ["@mvturnho", "@danielschramm"] +DOMAIN = "canbus" IS_PLATFORM_COMPONENT = True CONF_CAN_ID = "can_id" @@ -19,6 +23,9 @@ CONF_REMOTE_TRANSMISSION_REQUEST = "remote_transmission_request" CONF_CANBUS_ID = "canbus_id" CONF_BIT_RATE = "bit_rate" CONF_ON_FRAME = "on_frame" +CONF_MAX_FRAMES_PER_LOOP = "max_frames_per_loop" +DEFAULT_MAX_FRAMES_PER_LOOP = 50 # Keep in sync with max_frames_per_loop_ in canbus.h +DEFAULT_BIT_RATE = "125KBPS" # Keep in sync with bit_rate_ in canbus.h def validate_id(config: ConfigType) -> ConfigType: @@ -86,8 +93,13 @@ CANBUS_SCHEMA = cv.Schema( { cv.GenerateID(): cv.declare_id(CanbusComponent), cv.Required(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_BIT_RATE, default="125KBPS"): cv.enum(CAN_SPEEDS, upper=True), + cv.Optional(CONF_BIT_RATE, default=DEFAULT_BIT_RATE): cv.enum( + CAN_SPEEDS, upper=True + ), cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, + cv.Optional( + CONF_MAX_FRAMES_PER_LOOP, default=DEFAULT_MAX_FRAMES_PER_LOOP + ): cv.positive_not_null_int, cv.Optional(CONF_ON_FRAME): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CanbusTrigger), @@ -108,9 +120,13 @@ CANBUS_SCHEMA.add_extra(validate_id) async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: await cg.register_component(var, config) - cg.add(var.set_can_id([config[CONF_CAN_ID]])) - cg.add(var.set_use_extended_id([config[CONF_USE_EXTENDED_ID]])) - cg.add(var.set_bitrate(CAN_SPEEDS[config[CONF_BIT_RATE]])) + cg.add(var.set_can_id(config[CONF_CAN_ID])) + if config[CONF_USE_EXTENDED_ID]: + cg.add(var.set_use_extended_id(True)) + if (bit_rate := config[CONF_BIT_RATE]) != DEFAULT_BIT_RATE: + cg.add(var.set_bitrate(CAN_SPEEDS[bit_rate])) + if (max_frames := config[CONF_MAX_FRAMES_PER_LOOP]) != DEFAULT_MAX_FRAMES_PER_LOOP: + cg.add(var.set_max_frames_per_loop(max_frames)) for conf in config.get(CONF_ON_FRAME, []): can_id = conf[CONF_CAN_ID] @@ -137,27 +153,43 @@ async def setup_canbus_core_(var: MockObj, config: ConfigType) -> None: ) -async def register_canbus(var: MockObj, config: ConfigType) -> None: +async def register_canbus( + var: MockObj, config: ConfigType, *, reports_status: bool | None = None +) -> None: if not CORE.has_id(config[CONF_ID]): var = cg.new_Pvariable(config[CONF_ID], var) + if reports_status is None: + _LOGGER.warning( + "CAN bus platform '%s' does not report bus state, so bus-off recovery and " + "error-state logging are disabled. Platform authors should either override " + "get_status() and get_events() and call register_canbus(..., reports_status=True) " + "or pass reports_status=False if the hardware cannot report it", + config[CONF_PLATFORM], + ) await setup_canbus_core_(var, config) # Actions +CANBUS_SEND_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(CONF_CANBUS_ID): cv.use_id(CanbusComponent), + cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), + cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, + cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST, default=False): cv.boolean, + # One classic CAN frame + cv.Required(CONF_DATA): cv.templatable( + cv.All(validate_raw_data, cv.Length(max=8)) + ), + }, + validate_id, + key=CONF_DATA, +) + + @automation.register_action( "canbus.send", canbus_ns.class_("CanbusSendAction", automation.Action), - cv.maybe_simple_value( - { - cv.GenerateID(CONF_CANBUS_ID): cv.use_id(CanbusComponent), - cv.Optional(CONF_CAN_ID): cv.int_range(min=0, max=0x1FFFFFFF), - cv.Optional(CONF_USE_EXTENDED_ID, default=False): cv.boolean, - cv.Optional(CONF_REMOTE_TRANSMISSION_REQUEST, default=False): cv.boolean, - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), - }, - validate_id, - key=CONF_DATA, - ), + CANBUS_SEND_ACTION_SCHEMA, synchronous=True, ) async def canbus_action_to_code( @@ -177,15 +209,11 @@ async def canbus_action_to_code( var.set_remote_transmission_request(config[CONF_REMOTE_TRANSMISSION_REQUEST]) ) - data = config[CONF_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: - if isinstance(data, bytes): - data = [int(x) for x in data] - # 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, + "canbus_data", + ) return var diff --git a/esphome/components/canbus/canbus.cpp b/esphome/components/canbus/canbus.cpp index c5ba59a645..dfe3ab9fd3 100644 --- a/esphome/components/canbus/canbus.cpp +++ b/esphome/components/canbus/canbus.cpp @@ -1,10 +1,11 @@ #include "canbus.h" #include +#include "esphome/core/application.h" #include "esphome/core/log.h" namespace esphome::canbus { -static const char *const TAG = "canbus"; +ESPHOME_LOG_TAG(TAG, "canbus"); void Canbus::setup() { if (!this->setup_internal()) { @@ -22,10 +23,11 @@ void Canbus::dump_config() { } canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, - const std::vector &data) { + const uint8_t *data, size_t len) { struct CanFrame can_message; - uint8_t size = static_cast(data.size()); + // Clamp before narrowing, so a lambda payload of 256 bytes or more cannot wrap to a short length + const uint8_t size = static_cast(std::min(len, CAN_MAX_DATA_LENGTH)); if (use_extended_id) { ESP_LOGD(TAG, "send extended id=0x%08" PRIx32 " rtr=%s size=%d", can_id, TRUEFALSE(remote_transmission_request), size); @@ -33,8 +35,6 @@ canbus::Error Canbus::send_data(uint32_t can_id, bool use_extended_id, bool remo ESP_LOGD(TAG, "send standard id=0x%03" PRIx32 " rtr=%s size=%d", can_id, TRUEFALSE(remote_transmission_request), size); } - if (size > CAN_MAX_DATA_LENGTH) - size = CAN_MAX_DATA_LENGTH; can_message.can_data_length_code = size; can_message.can_id = can_id; can_message.use_extended_id = use_extended_id; @@ -66,17 +66,25 @@ void Canbus::add_trigger(CanbusTrigger *trigger) { }; void Canbus::loop() { + uint32_t now = App.get_loop_component_start_time(); + if ((now - this->last_event_check_time_) >= EVENT_CHECK_INTERVAL_MS) { + this->log_events_(this->get_events(), now); + this->last_event_check_time_ = now; + } + struct CanFrame can_message; - // read all messages until queue is empty - int message_counter = 0; - while (this->read_message(&can_message) == canbus::ERROR_OK) { - message_counter++; + uint32_t message_counter = 0; + for (; message_counter < this->max_frames_per_loop_; message_counter++) { + if (this->read_message(&can_message) != canbus::ERROR_OK) { + break; + } + if (can_message.use_extended_id) { - ESP_LOGD(TAG, "received can message (#%d) extended can_id=0x%" PRIx32 " size=%d", message_counter, + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") extended can_id=0x%" PRIx32 " size=%d", message_counter + 1, can_message.can_id, can_message.can_data_length_code); } else { - ESP_LOGD(TAG, "received can message (#%d) std can_id=0x%" PRIx32 " size=%d", message_counter, can_message.can_id, - can_message.can_data_length_code); + ESP_LOGD(TAG, "received can message (#%" PRIu32 ") std can_id=0x%" PRIx32 " size=%d", message_counter + 1, + can_message.can_id, can_message.can_data_length_code); } std::vector data; @@ -100,6 +108,63 @@ void Canbus::loop() { } } } + + if (message_counter == this->max_frames_per_loop_) { + ESP_LOGW(TAG, + "Reached max_frames_per_loop=%" PRIu32 ", deferring remaining CAN frames. Some frames might be dropped", + this->max_frames_per_loop_); + } +} + +void Canbus::log_events_(CanEventFlags events, uint32_t now) { + // Bus-off gets its own handling because automatic recovery can make it flap + if (events & CanEventFlags::CAN_EVENT_BUS_OFF) { + this->last_bus_off_time_ = now; + if (!this->bus_off_) { + this->bus_off_ = true; + this->status_set_warning(LOG_STR("Bus off")); + } + } else if (this->bus_off_) { + if ((now - this->last_bus_off_time_) >= EVENT_LOG_BUS_OFF_HOLDOFF_MS) { + // No bus-off event for the hold-off time, so ask whether it is really over + if (this->get_status().bus_off) { + this->last_bus_off_time_ = now; + } else { + this->bus_off_ = false; + this->status_clear_warning(); + // Events gathered while bus-off say nothing about the recovered bus + this->events_to_log_ = 0; + } + } + } else { + this->events_to_log_ |= events; + if (this->events_to_log_ != 0 && (now - this->last_event_log_time_) >= EVENT_LOG_THROTTLE_MS) { + if (this->events_to_log_ & (CanEventFlags::CAN_EVENT_PASSIVE | CanEventFlags::CAN_EVENT_ACTIVE)) { + bool error_passive = this->events_to_log_ & CanEventFlags::CAN_EVENT_PASSIVE; + if (error_passive && (this->events_to_log_ & CanEventFlags::CAN_EVENT_ACTIVE)) { + // Both edges within one throttle window, so the counters decide the current state + auto status = this->get_status(); + error_passive = status.rx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD || + status.tx_error_counter >= CAN_ERROR_PASSIVE_THRESHOLD; + } + ESP_LOGW(TAG, "entered %s", error_passive ? LOG_STR_LITERAL("error-passive") : LOG_STR_LITERAL("error-active")); + } + if (this->events_to_log_ & CanEventFlags::CAN_EVENT_RX_QUEUE_FULL) { + ESP_LOGD(TAG, "receive buffer overrun"); + } + this->events_to_log_ = 0; + this->last_event_log_time_ = now; + } + } + +#ifdef ESPHOME_LOG_HAS_VERBOSE + if ((now - this->last_state_log_time_) >= STATE_LOG_INTERVAL_MS) { + auto status = this->get_status(); + ESP_LOGV(TAG, "Status: bus_off %d, tx_err %d, rx_err %d", status.bus_off, status.tx_error_counter, + status.rx_error_counter); + this->last_state_log_time_ = now; + } +#endif } } // namespace esphome::canbus diff --git a/esphome/components/canbus/canbus.h b/esphome/components/canbus/canbus.h index 1bc4d6e345..077ac9d4cd 100644 --- a/esphome/components/canbus/canbus.h +++ b/esphome/components/canbus/canbus.h @@ -48,6 +48,8 @@ template class CanbusSendAction; /* CAN payload length definitions according to ISO 11898-1 */ static const uint8_t CAN_MAX_DATA_LENGTH = 8; +static const uint8_t CAN_ERROR_PASSIVE_THRESHOLD = 128; + /* Can Frame describes a normative CAN Frame The RTR = Remote Transmission Request is implemented in every CAN controller but rarely used @@ -61,6 +63,26 @@ struct CanFrame { uint8_t data[CAN_MAX_DATA_LENGTH] __attribute__((aligned(8))); }; +enum CanEventFlags : uint8_t { + CAN_EVENT_PASSIVE = 1 << 0, + CAN_EVENT_ACTIVE = 1 << 1, + CAN_EVENT_BUS_OFF = 1 << 2, + CAN_EVENT_RX_QUEUE_FULL = 1 << 3, +}; + +static constexpr uint32_t CAN_STATUS_UNSET = UINT32_MAX; + +struct CanStatus { + bool bus_off{false}; + uint8_t rx_error_counter{0}; + uint8_t tx_error_counter{0}; + uint32_t tx_failed_count{CAN_STATUS_UNSET}; + uint32_t rx_missed_count{CAN_STATUS_UNSET}; + uint32_t rx_overrun_count{CAN_STATUS_UNSET}; + uint32_t arb_lost_count{CAN_STATUS_UNSET}; + uint32_t bus_error_count{CAN_STATUS_UNSET}; +}; + class Canbus : public Component { public: Canbus(){}; @@ -69,8 +91,12 @@ class Canbus : public Component { float get_setup_priority() const override { return setup_priority::HARDWARE; } void loop() override; + canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, const uint8_t *data, + size_t len); canbus::Error send_data(uint32_t can_id, bool use_extended_id, bool remote_transmission_request, - const std::vector &data); + const std::vector &data) { + return this->send_data(can_id, use_extended_id, remote_transmission_request, data.data(), data.size()); + } canbus::Error send_data(uint32_t can_id, bool use_extended_id, const std::vector &data) { // for backwards compatibility only return this->send_data(can_id, use_extended_id, false, data); @@ -78,6 +104,8 @@ class Canbus : public Component { void set_can_id(uint32_t can_id) { this->can_id_ = can_id; } void set_use_extended_id(bool use_extended_id) { this->use_extended_id_ = use_extended_id; } void set_bitrate(CanSpeed bit_rate) { this->bit_rate_ = bit_rate; } + void set_max_frames_per_loop(uint32_t max_frames_per_loop) { this->max_frames_per_loop_ = max_frames_per_loop; } + virtual CanStatus get_status() { return {}; } void add_trigger(CanbusTrigger *trigger); /** @@ -96,29 +124,40 @@ class Canbus : public Component { template friend class CanbusSendAction; std::vector triggers_{}; uint32_t can_id_; - bool use_extended_id_; - CanSpeed bit_rate_; + uint32_t max_frames_per_loop_{50}; // Keep in sync with DEFAULT_MAX_FRAMES_PER_LOOP in __init__.py + CanSpeed bit_rate_{CAN_125KBPS}; // Keep in sync with DEFAULT_BIT_RATE in __init__.py + bool use_extended_id_{false}; + uint8_t events_to_log_{0}; + bool bus_off_{false}; CallbackManager &data)> callback_manager_{}; + // interval in which to check for new can bus events (bus-off, passive, active) + static constexpr uint32_t EVENT_CHECK_INTERVAL_MS = 100; + uint32_t last_event_check_time_{0}; + + // time to wait after logging an event before logging the next event(s) + static constexpr uint32_t EVENT_LOG_THROTTLE_MS = 1000; + uint32_t last_event_log_time_{0}; + + // time span without bus-off events before the bus is declared recovered + static constexpr uint32_t EVENT_LOG_BUS_OFF_HOLDOFF_MS = 1000; + uint32_t last_bus_off_time_{0}; + +#ifdef ESPHOME_LOG_HAS_VERBOSE + static constexpr uint32_t STATE_LOG_INTERVAL_MS = 1000; + uint32_t last_state_log_time_{0}; +#endif + + void log_events_(CanEventFlags events, uint32_t now); virtual bool setup_internal() = 0; virtual Error send_message(struct CanFrame *frame) = 0; virtual Error read_message(struct CanFrame *frame) = 0; + virtual CanEventFlags get_events() { return CanEventFlags{}; } }; template class CanbusSendAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers - this->data_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - // Store pointer to static data in flash (no RAM copy) - 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 set_can_id(uint32_t can_id) { this->can_id_ = can_id; } @@ -132,26 +171,17 @@ template class CanbusSendAction final : public Action, pu auto can_id = this->can_id_.has_value() ? *this->can_id_ : this->parent_->can_id_; auto use_extended_id = this->use_extended_id_.has_value() ? *this->use_extended_id_ : this->parent_->use_extended_id_; - 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_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data); + this->data_.template visit( + [&](const uint8_t *data, size_t len) { + this->parent_->send_data(can_id, use_extended_id, this->remote_transmission_request_, data, len); + }, + x...); } protected: optional can_id_{}; optional use_extended_id_{}; bool remote_transmission_request_{false}; - 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_; }; class CanbusTrigger final : public Trigger, uint32_t, bool>, public Component { diff --git a/esphome/components/cap1188/__init__.py b/esphome/components/cap1188/__init__.py index eff0a05163..24f6e627d2 100644 --- a/esphome/components/cap1188/__init__.py +++ b/esphome/components/cap1188/__init__.py @@ -11,6 +11,7 @@ CONF_ALLOW_MULTIPLE_TOUCHES = "allow_multiple_touches" DEPENDENCIES = ["i2c"] AUTO_LOAD = ["binary_sensor", "output"] CODEOWNERS = ["@mreditor97"] +DOMAIN = "cap1188" cap1188_ns = cg.esphome_ns.namespace("cap1188") CONF_CAP1188_ID = "cap1188_id" diff --git a/esphome/components/cap1188/cap1188.cpp b/esphome/components/cap1188/cap1188.cpp index 125a4aba51..d6d97329db 100644 --- a/esphome/components/cap1188/cap1188.cpp +++ b/esphome/components/cap1188/cap1188.cpp @@ -4,7 +4,7 @@ namespace esphome::cap1188 { -static const char *const TAG = "cap1188"; +ESPHOME_LOG_TAG(TAG, "cap1188"); void CAP1188Component::setup() { this->disable_loop(); diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index e490d89062..8187dac008 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -3,7 +3,7 @@ import logging import esphome.codegen as cg from esphome.components import web_server_base, wifi from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID -from esphome.config_helpers import filter_source_files_from_platform +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_AP, @@ -15,7 +15,6 @@ from esphome.const import ( PLATFORM_LN882X, PLATFORM_RP2, PLATFORM_RTL87XX, - PlatformFramework, ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority @@ -34,6 +33,7 @@ def AUTO_LOAD() -> list[str]: DEPENDENCIES = ["wifi"] CODEOWNERS = ["@esphome/core"] +DOMAIN = "captive_portal" captive_portal_ns = cg.esphome_ns.namespace("captive_portal") CaptivePortal = captive_portal_ns.class_("CaptivePortal", cg.Component) @@ -63,7 +63,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() - wifi_conf = full_config.get("wifi") + wifi_conf = full_config.get(WIFI_DOMAIN) if wifi_conf is None: # This shouldn't happen due to DEPENDENCIES = ["wifi"], but check anyway @@ -76,17 +76,7 @@ def _final_validate(config: ConfigType) -> None: "Add 'ap:' to your WiFi configuration to enable the captive portal." ) - # Register socket needs for DNS server and additional HTTP connections - # - 1 UDP socket for DNS server - # - 3 TCP sockets for captive portal detection probes + configuration requests - # OS captive portal detection makes multiple probe requests that stay in TIME_WAIT. - # Need headroom for actual user configuration requests. - # LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts. - # The listening socket is registered by web_server_base (shared HTTP server). - from esphome.components import socket - - socket.consume_sockets(3, "captive_portal")(config) - socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) + web_server_base.consume_captive_dns_sockets(config, DOMAIN) FINAL_VALIDATE_SCHEMA = _final_validate @@ -106,16 +96,4 @@ async def to_code(config: ConfigType) -> None: if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_CAPTIVE_PORTAL_GZIP") - if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): - cg.add_library("DNSServer", None) - - -# Only compile the ESP-IDF DNS server when using ESP-IDF framework -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "dns_server_esp32_idf.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - } -) + web_server_base.add_captive_dns_library() diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 599422a46b..0aa48bc3ea 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -6,14 +6,11 @@ #include "esphome/core/string_ref.h" #include "esphome/components/wifi/scan_list.h" #include "esphome/components/wifi/wifi_component.h" -#ifdef USE_PROVISIONING -#include "esphome/components/provisioning/provisioning.h" -#endif #include "captive_index.h" namespace esphome::captive_portal { -static const char *const TAG = "captive_portal"; +ESPHOME_LOG_TAG(TAG, "captive_portal"); void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); @@ -81,20 +78,6 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { void CaptivePortal::setup() { // Disable loop by default - will be enabled when captive portal starts this->disable_loop(); -#ifdef USE_PROVISIONING - // The captive portal is a provisioning surface: once the provisioning window - // has closed, stop serving it. WiFi's own closed-callback shuts down the - // access point the portal runs on, and the gated fallback in WiFiComponent's - // loop() ensures neither is started again afterwards. - if (provisioning::global_provisioning_manager != nullptr) { - provisioning::global_provisioning_manager->add_on_closed_callback([this]() { - if (this->active_) { - ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal"); - this->end(); - } - }); - } -#endif } void CaptivePortal::start() { this->base_->init(); @@ -102,17 +85,7 @@ void CaptivePortal::start() { this->base_->add_handler_without_auth(this); } - network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); - -#if defined(USE_ESP32) - // Create DNS server instance for ESP-IDF - this->dns_server_ = make_unique(); - this->dns_server_->start(ip); -#elif defined(USE_ARDUINO) - this->dns_server_ = make_unique(); - this->dns_server_->setErrorReplyCode(DNSReplyCode::NoError); - this->dns_server_->start(53, ESPHOME_F("*"), ip); -#endif + this->dns_.start(wifi::global_wifi_component->wifi_soft_ap_ip()); this->initialized_ = true; this->active_ = true; diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index b47af9d978..013ba25b5f 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -1,16 +1,11 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_CAPTIVE_PORTAL -#include -#if defined(USE_ESP32) -#include "dns_server_esp32_idf.h" -#elif defined(USE_ARDUINO) -#include -#endif #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/web_server_base/captive_dns.h" namespace esphome::captive_portal { @@ -19,17 +14,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { CaptivePortal(web_server_base::WebServerBase *base); void setup() override; void dump_config() override; - void loop() override { -#if defined(USE_ESP32) - if (this->dns_server_ != nullptr) { - this->dns_server_->process_next_request(); - } -#elif defined(USE_ARDUINO) - if (this->dns_server_ != nullptr) { - this->dns_server_->processNextRequest(); - } -#endif - } + void loop() override { this->dns_.loop(); } float get_setup_priority() const override; void start(); bool is_active() const { return this->active_; } @@ -37,10 +22,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { this->active_ = false; this->disable_loop(); // Stop processing DNS requests this->base_->deinit(); - if (this->dns_server_ != nullptr) { - this->dns_server_->stop(); - this->dns_server_ = nullptr; - } + this->dns_.stop(); } bool canHandle(AsyncWebServerRequest *request) const override { @@ -60,9 +42,7 @@ class CaptivePortal final : public AsyncWebHandler, public Component { web_server_base::WebServerBase *base_; bool initialized_{false}; bool active_{false}; -#if defined(USE_ARDUINO) || defined(USE_ESP32) - std::unique_ptr dns_server_{nullptr}; -#endif + web_server_base::CaptiveDNS dns_; }; extern CaptivePortal *global_captive_portal; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/cc1101/__init__.py b/esphome/components/cc1101/__init__.py index dbafae4ad0..8b9e91f833 100644 --- a/esphome/components/cc1101/__init__.py +++ b/esphome/components/cc1101/__init__.py @@ -13,10 +13,10 @@ from esphome.const import ( CONF_VALUE, CONF_WAIT_TIME, ) -from esphome.core import ID CODEOWNERS = ["@lygris", "@gabest11"] DEPENDENCIES = ["spi"] +DOMAIN = "cc1101" MULTI_CONF = True ns = cg.esphome_ns.namespace("cc1101") @@ -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): @@ -395,7 +384,10 @@ def validate_raw_data(value): SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(CC1101Component), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + # TX FIFO size + cv.Required(CONF_DATA): cv.templatable( + cv.All(validate_raw_data, cv.Length(max=64)) + ), }, key=CONF_DATA, ) @@ -410,17 +402,13 @@ SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( async def send_packet_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], + args, + var.set_data_template, + var.set_data_static, + "cc1101_data", + ) return var diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index f7b90b91cf..94d89b899d 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -3,10 +3,16 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include + +#ifdef USE_ESP32 +#include +#endif namespace esphome::cc1101 { -static const char *const TAG = "cc1101"; +ESPHOME_LOG_TAG(TAG, "cc1101"); +static constexpr size_t TX_FIFO_SIZE = 64; static void split_float(float value, int mbits, uint8_t &e, uint32_t &m) { int e_tmp; @@ -253,11 +259,17 @@ void CC1101Component::dump_config() { } void CC1101Component::begin_tx() { + // Read the mode before the PKTCTRL0 write below overwrites it in state_ + const bool packet_mode = this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO); // Ensure Packet Format is 3 (Async Serial) this->write_(Register::PKTCTRL0, 0x32); ESP_LOGV(TAG, "Beginning TX sequence"); if (this->gdo0_pin_ != nullptr) { - this->gdo0_pin_->detach_interrupt(); + // setup() only attaches the interrupt in packet mode. In async serial mode the pin may be + // shared with remote_receiver, and detaching here would remove the receiver's interrupt. + if (packet_mode) { + this->gdo0_pin_->detach_interrupt(); + } this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); } // Transition through IDLE to bypass CCA (Clear Channel Assessment) which can @@ -379,18 +391,32 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) { this->disable(); } -CC1101Error CC1101Component::transmit_packet(const std::vector &packet) { +CC1101Error CC1101Component::transmit_packet(const uint8_t *data, size_t len) { if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { return CC1101Error::PARAMS; } + const bool variable = this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE); + // In variable length mode the FIFO also holds the length byte + if (len + (variable ? 1 : 0) > TX_FIFO_SIZE) { + ESP_LOGE(TAG, "Packet of %u bytes does not fit the TX FIFO", static_cast(len)); + return CC1101Error::PARAMS; + } // Write packet this->enter_idle_(); this->strobe_(Command::FTX); - if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { - this->write_(Register::FIFO, static_cast(packet.size())); + if (variable) { + this->write_(Register::FIFO, static_cast(len)); } - this->write_(Register::FIFO, packet.data(), packet.size()); +#ifdef USE_ESP32 + // SPI DMA cannot read flash or unaligned buffers; stage them here so the driver does not allocate a copy + alignas(4) uint8_t staged[TX_FIFO_SIZE]; + if (len != 0 && (!esp_ptr_dma_capable(data) || (reinterpret_cast(data) & 3) != 0)) { + std::memcpy(staged, data, len); + data = staged; + } +#endif + this->write_(Register::FIFO, data, len); // Calibrate PLL if (!this->enter_calibrated_(State::FSTXON, Command::FSTXON)) { diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 1f7a55fbf1..d7065c6048 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -89,7 +89,10 @@ class CC1101Component final : public Component, void set_whitening(bool value); // Packet mode operations - CC1101Error transmit_packet(const std::vector &packet); + CC1101Error transmit_packet(const std::vector &packet) { + return this->transmit_packet(packet.data(), packet.size()); + } + CC1101Error transmit_packet(const uint8_t *data, size_t len); void register_listener(CC1101Listener *listener) { this->listeners_.push_back(listener); } Trigger, float, float, uint8_t> *get_packet_trigger() { return &this->packet_trigger_; } @@ -130,48 +133,12 @@ class CC1101Component final : public Component, }; // Action Wrappers -template class BeginTxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_tx(); } -}; - -template class BeginRxAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->begin_rx(); } -}; - -template class ResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->reset(); } -}; - -template class SetIdleAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->set_idle(); } -}; - template class SendPacketAction final : public Action, public Parented { - public: - void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } - void set_data_static(const uint8_t *data, size_t len) { - this->data_static_ = data; - this->data_static_len_ = len; - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->data_func_) { - auto data = this->data_func_(x...); - this->parent_->transmit_packet(data); - } else if (this->data_static_ != nullptr) { - std::vector data(this->data_static_, this->data_static_ + this->data_static_len_); - this->parent_->transmit_packet(data); - } + this->data_.visit([this](const uint8_t *data, size_t len) { this->parent_->transmit_packet(data, len); }, x...); } - - protected: - std::function(Ts...)> data_func_{}; - const uint8_t *data_static_{nullptr}; - size_t data_static_len_{0}; }; } // namespace esphome::cc1101 diff --git a/esphome/components/ccs811/__init__.py b/esphome/components/ccs811/__init__.py index e69de29bb2..4a86948d39 100644 --- a/esphome/components/ccs811/__init__.py +++ b/esphome/components/ccs811/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ccs811" diff --git a/esphome/components/ccs811/ccs811.cpp b/esphome/components/ccs811/ccs811.cpp index afaeac55aa..126ed0049d 100644 --- a/esphome/components/ccs811/ccs811.cpp +++ b/esphome/components/ccs811/ccs811.cpp @@ -5,7 +5,7 @@ namespace esphome::ccs811 { -static const char *const TAG = "ccs811"; +ESPHOME_LOG_TAG(TAG, "ccs811"); // based on // - https://cdn.sparkfun.com/datasheets/BreakoutBoards/CCS811_Programming_Guide.pdf 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/cd74hc4067/__init__.py b/esphome/components/cd74hc4067/__init__.py index 5f7778e186..655b8db819 100644 --- a/esphome/components/cd74hc4067/__init__.py +++ b/esphome/components/cd74hc4067/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["sensor", "voltage_sampler"] CODEOWNERS = ["@asoehlke"] +DOMAIN = "cd74hc4067" MULTI_CONF = True cd74hc4067_ns = cg.esphome_ns.namespace("cd74hc4067") diff --git a/esphome/components/cd74hc4067/cd74hc4067.cpp b/esphome/components/cd74hc4067/cd74hc4067.cpp index c275aca75f..b0a042e868 100644 --- a/esphome/components/cd74hc4067/cd74hc4067.cpp +++ b/esphome/components/cd74hc4067/cd74hc4067.cpp @@ -4,7 +4,7 @@ namespace esphome::cd74hc4067 { -static const char *const TAG = "cd74hc4067"; +ESPHOME_LOG_TAG(TAG, "cd74hc4067"); void CD74HC4067Component::setup() { this->pin_s0_->setup(); diff --git a/esphome/components/cdc_acm_uart/__init__.py b/esphome/components/cdc_acm_uart/__init__.py index 516af84856..9d5c0a3e95 100644 --- a/esphome/components/cdc_acm_uart/__init__.py +++ b/esphome/components/cdc_acm_uart/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@kbx81"] +DOMAIN = "cdc_acm_uart" diff --git a/esphome/components/cdc_acm_uart/bridge/__init__.py b/esphome/components/cdc_acm_uart/bridge/__init__.py index cee048df5d..133ea92a48 100644 --- a/esphome/components/cdc_acm_uart/bridge/__init__.py +++ b/esphome/components/cdc_acm_uart/bridge/__init__.py @@ -36,19 +36,6 @@ CONFIG_SCHEMA = cv.All( ) -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. @@ -86,7 +73,7 @@ def _final_validate(config: ConfigType) -> ConfigType: for domain, domain_conf in full_config.items(): if domain == BRIDGE_DOMAIN: continue - if _subtree_references_uart(domain_conf, owned_id): + if uart.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}.", 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 index 042688bfe6..a1bf0a1972 100644 --- a/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp +++ b/esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.cpp @@ -13,7 +13,7 @@ namespace esphome::cdc_acm_uart { -static const char *const TAG = "cdc_acm_uart"; +ESPHOME_LOG_TAG(TAG, "cdc_acm_uart"); static constexpr size_t UART_TASK_STACK_SIZE = 4096; static constexpr size_t RINGBUF_RETRY_CHUNK_SIZE = 64; diff --git a/esphome/components/ch422g/__init__.py b/esphome/components/ch422g/__init__.py index 7f0c5bb95e..5110503bf8 100644 --- a/esphome/components/ch422g/__init__.py +++ b/esphome/components/ch422g/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesterret", "@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "ch422g" MULTI_CONF = True ch422g_ns = cg.esphome_ns.namespace("ch422g") diff --git a/esphome/components/ch422g/ch422g.cpp b/esphome/components/ch422g/ch422g.cpp index 85603f46f4..28b355a4f1 100644 --- a/esphome/components/ch422g/ch422g.cpp +++ b/esphome/components/ch422g/ch422g.cpp @@ -10,7 +10,7 @@ static const uint8_t CH422G_REG_IN = 0x26; // read reg for input bits static const uint8_t CH422G_REG_OUT = 0x38; // write reg for output bits 0-7 static const uint8_t CH422G_REG_OUT_UPPER = 0x23; // write reg for output bits 8-11 -static const char *const TAG = "ch422g"; +ESPHOME_LOG_TAG(TAG, "ch422g"); void CH422GComponent::setup() { // set outputs before mode diff --git a/esphome/components/ch423/__init__.py b/esphome/components/ch423/__init__.py index 9fbf3ea515..3997f9581f 100644 --- a/esphome/components/ch423/__init__.py +++ b/esphome/components/ch423/__init__.py @@ -19,6 +19,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@dwmw2"] DEPENDENCIES = ["i2c"] +DOMAIN = "ch423" MULTI_CONF = True ch423_ns = cg.esphome_ns.namespace("ch423") @@ -59,7 +60,7 @@ def pin_mode_check(pin_config: ConfigType, _: ConfigType) -> None: is_open_drain = pin_config[CONF_MODE][CONF_OPEN_DRAIN] # Track pin modes per CH423 instance in CORE.data - ch423_modes = CORE.data.setdefault(CONF_CH423, {}) + ch423_modes = CORE.data.setdefault(DOMAIN, {}) if ch423_id not in ch423_modes: ch423_modes[ch423_id] = {"gpio_output": None, "gpo_open_drain": None} diff --git a/esphome/components/ch423/ch423.cpp b/esphome/components/ch423/ch423.cpp index 8424d130b4..b7e9d224fa 100644 --- a/esphome/components/ch423/ch423.cpp +++ b/esphome/components/ch423/ch423.cpp @@ -12,7 +12,7 @@ static constexpr uint8_t CH423_REG_IO_RD = 0x26; // Read IO7-IO0 (0x4D >> 1, ro static constexpr uint8_t CH423_REG_OCL = 0x22; // Write OC7-OC0 (0x44 >> 1) static constexpr uint8_t CH423_REG_OCH = 0x23; // Write OC15-OC8 (0x46 >> 1) -static const char *const TAG = "ch423"; +ESPHOME_LOG_TAG(TAG, "ch423"); void CH423Component::setup() { // set outputs before mode diff --git a/esphome/components/chsc6x/__init__.py b/esphome/components/chsc6x/__init__.py index 80f3a0b33e..bbdc870773 100644 --- a/esphome/components/chsc6x/__init__.py +++ b/esphome/components/chsc6x/__init__.py @@ -1,2 +1,3 @@ CODEOWNERS = ["@kkosik20"] DEPENDENCIES = ["i2c"] +DOMAIN = "chsc6x" diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index e64092d30e..a59c03ed1e 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -46,7 +46,6 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_LOW_STATE_TOPIC, CONF_TARGET_TEMPERATURE_STATE_TOPIC, CONF_TEMPERATURE_STEP, - CONF_TRIGGER_ID, CONF_VISUAL, CONF_WEB_SERVER, ) @@ -62,6 +61,7 @@ from esphome.types import ConfigType, SafeExpType IS_PLATFORM_COMPONENT = True CODEOWNERS = ["@esphome/core"] +DOMAIN = "climate" climate_ns = cg.esphome_ns.namespace("climate") Climate = climate_ns.class_("Climate", cg.EntityBase) @@ -163,14 +163,6 @@ def visual_temperature_step(value: Any) -> ConfigType: ) -# Actions -StateTrigger = climate_ns.class_( - "StateTrigger", automation.Trigger.template(Climate.operator("ref")) -) -ControlTrigger = climate_ns.class_( - "ControlTrigger", automation.Trigger.template(ClimateCall.operator("ref")) -) - _CLIMATE_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -249,16 +241,8 @@ _CLIMATE_SCHEMA = ( cv.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): cv.All( cv.requires_component("mqtt"), cv.publish_topic ), - cv.Optional(CONF_ON_CONTROL): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ControlTrigger), - } - ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger), - } - ), + cv.Optional(CONF_ON_CONTROL): automation.validate_automation(), + cv.Optional(CONF_ON_STATE): automation.validate_automation(), } ) ) @@ -287,6 +271,16 @@ def climate_schema( return _CLIMATE_SCHEMA.extend(schema) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_STATE, "add_on_state_callback", [(Climate.operator("ref"), "x")] + ), + automation.CallbackAutomation( + CONF_ON_CONTROL, "add_on_control_callback", [(ClimateCall.operator("ref"), "x")] + ), +) + + @setup_entity("climate") async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: visual = config.get(CONF_VISUAL, {}) @@ -442,17 +436,7 @@ async def setup_climate_core_(var: MockObj, config: ConfigType) -> None: ) ) - for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(Climate.operator("ref"), "x")], conf - ) - - for conf in config.get(CONF_ON_CONTROL, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(ClimateCall.operator("ref"), "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h deleted file mode 100644 index d6aba02f92..0000000000 --- a/esphome/components/climate/automation.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "climate.h" - -namespace esphome::climate { - -class ControlTrigger final : public Trigger { - public: - ControlTrigger(Climate *climate) { - climate->add_on_control_callback([this](ClimateCall &x) { this->trigger(x); }); - } -}; - -class StateTrigger final : public Trigger { - public: - StateTrigger(Climate *climate) { - climate->add_on_state_callback([this](Climate &x) { this->trigger(x); }); - } -}; - -} // namespace esphome::climate diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index f80de151b1..790307cec1 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -6,7 +6,7 @@ namespace esphome::climate { -static const char *const TAG = "climate"; +ESPHOME_LOG_TAG(TAG, "climate"); // Memory-efficient lookup tables struct StringToUint8 { @@ -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 f5a31b31c4..f4b43e3a52 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -19,6 +19,7 @@ CONF_SUPPORTS_HEAT_COOL = "supports_heat_cool" DEPENDENCIES = ["remote_transmitter"] AUTO_LOAD = ["sensor", "remote_base"] CODEOWNERS = ["@glmnet"] +DOMAIN = "climate_ir" climate_ir_ns = cg.esphome_ns.namespace("climate_ir") ClimateIR = climate_ir_ns.class_( diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index 47143e6e60..622aff243c 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -3,7 +3,7 @@ namespace esphome::climate_ir { -static const char *const TAG = "climate_ir"; +ESPHOME_LOG_TAG(TAG, "climate_ir"); climate::ClimateTraits ClimateIR::traits() { auto traits = climate::ClimateTraits(); diff --git a/esphome/components/climate_ir_lg/__init__.py b/esphome/components/climate_ir_lg/__init__.py index e69de29bb2..b62e9d17fb 100644 --- a/esphome/components/climate_ir_lg/__init__.py +++ b/esphome/components/climate_ir_lg/__init__.py @@ -0,0 +1 @@ +DOMAIN = "climate_ir_lg" diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index bb612eda7b..042d1dcb21 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -3,7 +3,7 @@ namespace esphome::climate_ir_lg { -static const char *const TAG = "climate.climate_ir_lg"; +ESPHOME_LOG_TAG(TAG, "climate.climate_ir_lg"); // All codes provided here are missing the checksum (last 4 bits) // this checksum needs to be calculated before sending (look at `calc_checksum_()`) diff --git a/esphome/components/cm1106/__init__.py b/esphome/components/cm1106/__init__.py index fa3c3f1925..24203304ae 100644 --- a/esphome/components/cm1106/__init__.py +++ b/esphome/components/cm1106/__init__.py @@ -1 +1,3 @@ """CM1106 component for ESPHome.""" + +DOMAIN = "cm1106" diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp index 2e3352b895..edfcd0f730 100644 --- a/esphome/components/cm1106/cm1106.cpp +++ b/esphome/components/cm1106/cm1106.cpp @@ -5,11 +5,17 @@ namespace esphome::cm1106 { -static const char *const TAG = "cm1106"; -static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; -static const uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; -static const uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; +ESPHOME_LOG_TAG(TAG, "cm1106"); +static constexpr uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; +static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; +static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; +// The factory default ABC calibration cycle differs between CM1106 models, so +// every ABC write applies the configured cycle and baseline explicitly. +static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS[10] = {0x11, 0x07, 0x10, 0x64, 0x00, 0x0F, 0x01, 0x90, 0x64, 0x00}; +static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS_RESPONSE[4] = {0x16, 0x01, 0x10, 0xD9}; +static constexpr uint8_t CM1106_ABC_FLAG_ENABLE = 0x0; +static constexpr uint8_t CM1106_ABC_FLAG_DISABLE = 0x2; uint8_t cm1106_checksum(const uint8_t *response, size_t len) { uint8_t crc = 0; for (size_t i = 0; i < len - 1; i++) { @@ -25,34 +31,46 @@ void CM1106Component::setup() { this->mark_failed(); return; } + + if (this->abc_boot_logic_ != CM1106_ABC_NONE) { + this->abc_set_(this->abc_boot_logic_ == CM1106_ABC_ENABLED); + } } void CM1106Component::update() { uint8_t response[8] = {0}; if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) { - ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + ESP_LOGW(TAG, "Reading data failed!"); this->status_set_warning(); return; } if (response[0] != 0x16 || response[1] != 0x05 || response[2] != 0x01) { - ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], - response[3]); + ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); this->status_set_warning(); return; } uint8_t checksum = cm1106_checksum(response, sizeof(response)); if (response[7] != checksum) { - ESP_LOGW(TAG, "CM1106 Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); + ESP_LOGW(TAG, "Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); this->status_set_warning(); return; } + const uint16_t ppm = response[3] << 8 | response[4]; + const uint8_t status = response[5]; + if (status & 0x1) { + ESP_LOGW(TAG, "Preheating; CO₂=%uppm", ppm); + this->status_set_warning(); + return; + } + if (status) { + ESP_LOGW(TAG, "Status: %02X", status); + } this->status_clear_warning(); - uint16_t ppm = response[3] << 8 | response[4]; - ESP_LOGD(TAG, "CM1106 Received CO₂=%uppm DF3=%02X DF4=%02X", ppm, response[5], response[6]); + ESP_LOGD(TAG, "CO₂=%uppm DF4=%02X", ppm, response[6]); if (this->co2_sensor_ != nullptr) this->co2_sensor_->publish_state(ppm); } @@ -62,24 +80,38 @@ void CM1106Component::calibrate_zero(uint16_t ppm) { memcpy(cmd, C_M1106_CMD_SET_CO2_CALIB, sizeof(cmd)); cmd[3] = ppm >> 8; cmd[4] = ppm & 0xFF; + if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_CO2_CALIB_RESPONSE)) { + ESP_LOGD(TAG, "Successfully calibrated sensor to %uppm", ppm); + } +} + +void CM1106Component::abc_set_(bool enabled) { + ESP_LOGD(TAG, "%sabling automatic baseline calibration", enabled ? LOG_STR_LITERAL("En") : LOG_STR_LITERAL("Dis")); + uint8_t cmd[10]; + memcpy(cmd, C_M1106_CMD_SET_ABC_STATUS, sizeof(cmd)); + cmd[4] = enabled ? CM1106_ABC_FLAG_ENABLE : CM1106_ABC_FLAG_DISABLE; + cmd[5] = this->abc_cycle_; + cmd[6] = this->abc_baseline_ >> 8; + cmd[7] = this->abc_baseline_ & 0xFF; + if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_ABC_STATUS_RESPONSE)) { + ESP_LOGD(TAG, "Successfully set ABC status"); + } +} + +bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack) { uint8_t response[4] = {0}; - - if (!this->cm1106_write_command_(cmd, sizeof(cmd), response, sizeof(response))) { - ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) { + ESP_LOGW(TAG, "Command %02X failed", command[2]); this->status_set_warning(); - return; + return false; } - - // check if correct response received - if (memcmp(response, C_M1106_CMD_SET_CO2_CALIB_RESPONSE, sizeof(response)) != 0) { - ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], - response[3]); + if (memcmp(response, ack, sizeof(response)) != 0) { + ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]); this->status_set_warning(); - return; + return false; } - this->status_clear_warning(); - ESP_LOGD(TAG, "CM1106 Successfully calibrated sensor to %uppm", ppm); + return true; } bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, @@ -100,6 +132,13 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma void CM1106Component::dump_config() { ESP_LOGCONFIG(TAG, "CM1106:"); LOG_SENSOR(" ", "CO2", this->co2_sensor_); + if (this->abc_boot_logic_ != CM1106_ABC_NONE) { + ESP_LOGCONFIG(TAG, + " Automatic baseline calibration on boot: %s\n" + " ABC calibration cycle: %u days\n" + " ABC baseline: %uppm", + ONOFF(this->abc_boot_logic_ == CM1106_ABC_ENABLED), this->abc_cycle_, this->abc_baseline_); + } if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h index 844bfdfa88..1066a5c44d 100644 --- a/esphome/components/cm1106/cm1106.h +++ b/esphome/components/cm1106/cm1106.h @@ -1,12 +1,19 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" namespace esphome::cm1106 { +// ABC logic requested at boot; CM1106_ABC_NONE means nothing is sent and the +// sensor keeps its stored setting. +enum CM1106ABCLogic : uint8_t { + CM1106_ABC_NONE = 0, + CM1106_ABC_ENABLED, + CM1106_ABC_DISABLED, +}; + class CM1106Component final : public PollingComponent, public uart::UARTDevice { public: void setup() override; @@ -14,23 +21,26 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice { void dump_config() override; void calibrate_zero(uint16_t ppm); + void abc_enable() { this->abc_set_(true); } + void abc_disable() { this->abc_set_(false); } void set_co2_sensor(sensor::Sensor *co2_sensor) { this->co2_sensor_ = co2_sensor; } + void set_abc(bool enabled, uint8_t cycle_days, uint16_t baseline_ppm) { + this->abc_boot_logic_ = enabled ? CM1106_ABC_ENABLED : CM1106_ABC_DISABLED; + this->abc_cycle_ = cycle_days; + this->abc_baseline_ = baseline_ppm; + } protected: - sensor::Sensor *co2_sensor_{nullptr}; - bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); -}; + /// Sends a command and checks the sensor's four byte acknowledgement, updating the warning status. + bool send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack); + void abc_set_(bool enabled); -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_; + sensor::Sensor *co2_sensor_{nullptr}; + CM1106ABCLogic abc_boot_logic_{CM1106_ABC_NONE}; + uint8_t abc_cycle_{15}; + uint16_t abc_baseline_{400}; }; } // namespace esphome::cm1106 diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py index a36f0b0059..fa179f9667 100644 --- a/esphome/components/cm1106/sensor.py +++ b/esphome/components/cm1106/sensor.py @@ -4,30 +4,38 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION import esphome.config_validation as cv from esphome.const import ( + CONF_BASELINE, CONF_CO2, + CONF_CYCLE, CONF_ID, DEVICE_CLASS_CARBON_DIOXIDE, ICON_MOLECULE_CO2, 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"] CODEOWNERS = ["@andrewjswan"] + +# true enables ABC with these defaults, false disables it, a mapping enables it with custom values +_ABC_SETTINGS_SCHEMA = cv.Schema( + { + cv.Optional(CONF_CYCLE, default=15): cv.int_range(min=1, max=90), + cv.Optional(CONF_BASELINE, default=400): cv.int_range(min=400, max=10000), + } +) + + 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( @@ -40,6 +48,9 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_CARBON_DIOXIDE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_AUTOMATIC_BASELINE_CALIBRATION): cv.Any( + cv.boolean, _ABC_SETTINGS_SCHEMA + ), }, ) .extend(cv.polling_component_schema("60s")) @@ -60,9 +71,13 @@ 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) + + if (abc := config.get(CONF_AUTOMATIC_BASELINE_CALIBRATION)) is not None: + enabled = abc if isinstance(abc, bool) else True + settings = _ABC_SETTINGS_SCHEMA({}) if isinstance(abc, bool) else abc + cg.add(var.set_abc(enabled, settings[CONF_CYCLE], settings[CONF_BASELINE])) CALIBRATION_ACTION_SCHEMA = maybe_simple_id( @@ -72,18 +87,15 @@ 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) +for _name, _call in ( + ("cm1106.abc_enable", "abc_enable()"), + ("cm1106.abc_disable", "abc_disable()"), +): + automation.register_apply_action( + _name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/esphome/components/color/__init__.py b/esphome/components/color/__init__.py index 70240eff07..49978c29c2 100644 --- a/esphome/components/color/__init__.py +++ b/esphome/components/color/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType ColorStruct = cg.esphome_ns.struct("Color") +DOMAIN = "color" INSTANCE_TYPE = ColorStruct MULTI_CONF = True diff --git a/esphome/components/color_temperature/__init__.py b/esphome/components/color_temperature/__init__.py index e69de29bb2..2c38fe6fc6 100644 --- a/esphome/components/color_temperature/__init__.py +++ b/esphome/components/color_temperature/__init__.py @@ -0,0 +1 @@ +DOMAIN = "color_temperature" diff --git a/esphome/components/combination/__init__.py b/esphome/components/combination/__init__.py index e69de29bb2..7625bf2d02 100644 --- a/esphome/components/combination/__init__.py +++ b/esphome/components/combination/__init__.py @@ -0,0 +1 @@ +DOMAIN = "combination" diff --git a/esphome/components/combination/combination.cpp b/esphome/components/combination/combination.cpp index 8ef0976e3b..0488fbb6ab 100644 --- a/esphome/components/combination/combination.cpp +++ b/esphome/components/combination/combination.cpp @@ -7,7 +7,9 @@ namespace esphome::combination { -static const char *const TAG = "combination"; +ESPHOME_LOG_TAG(TAG, "combination"); + +static constexpr uint32_t NEW_VALUE_DEFER_ID = 0; void CombinationComponent::log_config_(const LogString *combo_type) { LOG_SENSOR("", "Combination Sensor:", this); @@ -43,8 +45,9 @@ void CombinationNoParameterComponent::setup() { for (const auto &sensor : this->sensors_) { // All sensor updates are deferred until the next loop. This avoids publishing the combined sensor's result // repeatedly in the same loop if multiple source senors update. - sensor->add_on_state_callback( - [this](float value) -> void { this->defer("update", [this, value]() { this->handle_new_value(value); }); }); + sensor->add_on_state_callback([this](float value) -> void { + this->defer(NEW_VALUE_DEFER_ID, [this, value]() { this->handle_new_value(value); }); + }); } } @@ -119,8 +122,9 @@ void LinearCombinationComponent::setup() { for (auto &source : this->sensor_sources_) { // All sensor updates are deferred until the next loop. This avoids publishing the combined sensor's result // repeatedly in the same loop if multiple source senors update. - source.sensor->add_on_state_callback( - [this](float value) -> void { this->defer("update", [this, value]() { this->handle_new_value(value); }); }); + source.sensor->add_on_state_callback([this](float value) -> void { + this->defer(NEW_VALUE_DEFER_ID, [this, value]() { this->handle_new_value(value); }); + }); } } diff --git a/esphome/components/combination/sensor.py b/esphome/components/combination/sensor.py index ccc5a03964..608ebac4d6 100644 --- a/esphome/components/combination/sensor.py +++ b/esphome/components/combination/sensor.py @@ -1,5 +1,3 @@ -import logging - import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv @@ -18,8 +16,6 @@ from esphome.const import ( from esphome.core.entity_helpers import inherit_property_from from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@Cat-Ion", "@kahrendt"] combination_ns = cg.esphome_ns.namespace("combination") @@ -75,33 +71,19 @@ KALMAN_SOURCE_SCHEMA = cv.Schema( ) -def _migrate_coeffecient(config: ConfigType) -> ConfigType: - """Migrate deprecated 'coeffecient' spelling to 'coefficient'.""" - if CONF_COEFFECIENT in config: - if CONF_COEFFICIENT in config: - raise cv.Invalid( - f"Cannot specify both '{CONF_COEFFICIENT}' and '{CONF_COEFFECIENT}'" - ) - _LOGGER.warning( - "'%s' is deprecated, use '%s' instead. Will be removed in 2026.12.0", - CONF_COEFFECIENT, - CONF_COEFFICIENT, - ) - config[CONF_COEFFICIENT] = config.pop(CONF_COEFFECIENT) - elif CONF_COEFFICIENT not in config: - raise cv.Invalid(f"'{CONF_COEFFICIENT}' is a required option") - return config - - LINEAR_SOURCE_SCHEMA = cv.All( + cv.rename_key( + CONF_COEFFECIENT, + CONF_COEFFICIENT, + removed_in="2026.12.0", + component="combination", + ), cv.Schema( { cv.Required(CONF_SOURCE): cv.use_id(sensor.Sensor), - cv.Optional(CONF_COEFFICIENT): cv.templatable(cv.float_), - cv.Optional(CONF_COEFFECIENT): cv.templatable(cv.float_), + cv.Required(CONF_COEFFICIENT): cv.templatable(cv.float_), } ), - _migrate_coeffecient, ) SENSOR_ONLY_SOURCE_SCHEMA = cv.Schema( @@ -203,6 +185,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 31b0a0c097..78778553aa 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -1,28 +1,42 @@ """Constants used by esphome components.""" CODEOWNERS = ["@esphome/core"] +DOMAIN = "const" BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" CONF_ACCELEROMETER_ODR = "accelerometer_odr" CONF_ACCELEROMETER_RANGE = "accelerometer_range" +CONF_ADC_AVERAGING = "adc_averaging" +CONF_ADC_TIME = "adc_time" +CONF_ALLOWED_IPS = "allowed_ips" +CONF_AQI = "aqi" +CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" +CONF_BLUE_CHANNEL = "blue_channel" CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent" CONF_BYTE_ORDER = "byte_order" CONF_CHANNEL_COLORS = "channel_colors" +CONF_CLEAR_CHANNEL = "clear_channel" 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" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection" CONF_ENABLED = "enabled" +CONF_GREEN_CHANNEL = "green_channel" CONF_GYROSCOPE_ODR = "gyroscope_odr" CONF_GYROSCOPE_RANGE = "gyroscope_range" +CONF_HOLD_STATE = "hold_state" +CONF_HOST = "host" +CONF_HUMIDITY_SOURCE = "humidity_source" CONF_IAQ = "iaq" CONF_IGNORE_NOT_FOUND = "ignore_not_found" CONF_IS_WRGB = "is_wrgb" @@ -36,9 +50,13 @@ CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_SCAN_END = "on_scan_end" CONF_ON_STATE_CHANGE = "on_state_change" +CONF_ON_STOP = "on_stop" CONF_PARITY = "parity" CONF_RECEIVER_FREQUENCY = "receiver_frequency" +CONF_RECONNECT_INTERVAL = "reconnect_interval" +CONF_RED_CHANNEL = "red_channel" CONF_REQUEST_HEADERS = "request_headers" +CONF_ROLE = "role" CONF_ROWS = "rows" CONF_SCAN_PARAMETERS = "scan_parameters" CONF_SHA256 = "sha256" @@ -47,6 +65,7 @@ CONF_STATE_SAVE_INTERVAL = "state_save_interval" CONF_STOP_BITS = "stop_bits" CONF_TARGET_COUNT = "target_count" CONF_USE_PSRAM = "use_psram" +CONF_VERIFY_SSL = "verify_ssl" CONF_VOC_INDEX = "voc_index" CONF_VOLUME_INCREMENT = "volume_increment" CONF_VOLUME_INITIAL = "volume_initial" @@ -62,3 +81,4 @@ KEY_METADATA = "metadata" UNIT_AMPERE_HOUR = "Ah" UNIT_COUNTS = "#" +UNIT_MILLIAMPERE_HOUR = "mAh" 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/__init__.py b/esphome/components/coolix/__init__.py index e69de29bb2..e916a94238 100644 --- a/esphome/components/coolix/__init__.py +++ b/esphome/components/coolix/__init__.py @@ -0,0 +1 @@ +DOMAIN = "coolix" diff --git a/esphome/components/coolix/coolix.cpp b/esphome/components/coolix/coolix.cpp index 56eb43cd82..00a7b8a854 100644 --- a/esphome/components/coolix/coolix.cpp +++ b/esphome/components/coolix/coolix.cpp @@ -4,7 +4,7 @@ namespace esphome::coolix { -static const char *const TAG = "coolix.climate"; +ESPHOME_LOG_TAG(TAG, "coolix.climate"); static const uint32_t COOLIX_OFF = 0xB27BE0; static const uint32_t COOLIX_SWING = 0xB26BE0; diff --git a/esphome/components/copy/__init__.py b/esphome/components/copy/__init__.py index 7594894650..917991b2fc 100644 --- a/esphome/components/copy/__init__.py +++ b/esphome/components/copy/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg CODEOWNERS = ["@OttoWinter"] +DOMAIN = "copy" copy_ns = cg.esphome_ns.namespace("copy") diff --git a/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp b/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp index b084b0080a..80093a4a96 100644 --- a/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp +++ b/esphome/components/copy/binary_sensor/copy_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "copy.binary_sensor"); void CopyBinarySensor::setup() { source_->add_on_state_callback([this](bool value) { this->publish_state(value); }); diff --git a/esphome/components/copy/button/copy_button.cpp b/esphome/components/copy/button/copy_button.cpp index 297e1b0c94..c9441500a9 100644 --- a/esphome/components/copy/button/copy_button.cpp +++ b/esphome/components/copy/button/copy_button.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.button"; +ESPHOME_LOG_TAG(TAG, "copy.button"); void CopyButton::dump_config() { LOG_BUTTON("", "Copy Button", this); } diff --git a/esphome/components/copy/cover/copy_cover.cpp b/esphome/components/copy/cover/copy_cover.cpp index 3412784cc7..71bdef667e 100644 --- a/esphome/components/copy/cover/copy_cover.cpp +++ b/esphome/components/copy/cover/copy_cover.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.cover"; +ESPHOME_LOG_TAG(TAG, "copy.cover"); void CopyCover::setup() { source_->add_on_state_callback([this]() { diff --git a/esphome/components/copy/fan/copy_fan.cpp b/esphome/components/copy/fan/copy_fan.cpp index 6eed787b05..1e721332be 100644 --- a/esphome/components/copy/fan/copy_fan.cpp +++ b/esphome/components/copy/fan/copy_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.fan"; +ESPHOME_LOG_TAG(TAG, "copy.fan"); void CopyFan::setup() { // Copy preset modes once from source fan — stored on Fan base class diff --git a/esphome/components/copy/lock/copy_lock.cpp b/esphome/components/copy/lock/copy_lock.cpp index 5fdb9a757b..d8b0b17f84 100644 --- a/esphome/components/copy/lock/copy_lock.cpp +++ b/esphome/components/copy/lock/copy_lock.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.lock"; +ESPHOME_LOG_TAG(TAG, "copy.lock"); void CopyLock::setup() { source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); }); diff --git a/esphome/components/copy/number/copy_number.cpp b/esphome/components/copy/number/copy_number.cpp index a10bd209f4..7ddc7da170 100644 --- a/esphome/components/copy/number/copy_number.cpp +++ b/esphome/components/copy/number/copy_number.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.number"; +ESPHOME_LOG_TAG(TAG, "copy.number"); void CopyNumber::setup() { source_->add_on_state_callback([this](float value) { this->publish_state(value); }); diff --git a/esphome/components/copy/select/copy_select.cpp b/esphome/components/copy/select/copy_select.cpp index e4f97f3c16..2315514ac5 100644 --- a/esphome/components/copy/select/copy_select.cpp +++ b/esphome/components/copy/select/copy_select.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.select"; +ESPHOME_LOG_TAG(TAG, "copy.select"); void CopySelect::setup() { source_->add_on_state_callback([this](size_t index) { this->publish_state(index); }); diff --git a/esphome/components/copy/sensor/copy_sensor.cpp b/esphome/components/copy/sensor/copy_sensor.cpp index 9df23ab92d..17eee92c12 100644 --- a/esphome/components/copy/sensor/copy_sensor.cpp +++ b/esphome/components/copy/sensor/copy_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.sensor"; +ESPHOME_LOG_TAG(TAG, "copy.sensor"); void CopySensor::setup() { source_->add_on_state_callback([this](float value) { this->publish_state(value); }); diff --git a/esphome/components/copy/switch/copy_switch.cpp b/esphome/components/copy/switch/copy_switch.cpp index 555f0030a5..51850f898a 100644 --- a/esphome/components/copy/switch/copy_switch.cpp +++ b/esphome/components/copy/switch/copy_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.switch"; +ESPHOME_LOG_TAG(TAG, "copy.switch"); void CopySwitch::setup() { source_->add_on_state_callback([this](float value) { this->publish_state(value); }); diff --git a/esphome/components/copy/text/copy_text.cpp b/esphome/components/copy/text/copy_text.cpp index 5ec3ba05b7..fe8e1a010a 100644 --- a/esphome/components/copy/text/copy_text.cpp +++ b/esphome/components/copy/text/copy_text.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.text"; +ESPHOME_LOG_TAG(TAG, "copy.text"); void CopyText::setup() { source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); }); diff --git a/esphome/components/copy/text_sensor/copy_text_sensor.cpp b/esphome/components/copy/text_sensor/copy_text_sensor.cpp index 97182e8a61..96a0aa32b5 100644 --- a/esphome/components/copy/text_sensor/copy_text_sensor.cpp +++ b/esphome/components/copy/text_sensor/copy_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::copy { -static const char *const TAG = "copy.text_sensor"; +ESPHOME_LOG_TAG(TAG, "copy.text_sensor"); void CopyTextSensor::setup() { source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); }); diff --git a/esphome/components/counter/__init__.py b/esphome/components/counter/__init__.py new file mode 100644 index 0000000000..a85a88d6e6 --- /dev/null +++ b/esphome/components/counter/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@clydebarrow"] +DOMAIN = "counter" diff --git a/esphome/components/counter/counter_sensor.cpp b/esphome/components/counter/counter_sensor.cpp new file mode 100644 index 0000000000..c2f6688b2d --- /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 { + +ESPHOME_LOG_TAG(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 0f5aa9b450..c570f3d4ca 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -62,6 +62,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ] +DOMAIN = "cover" _LOGGER = logging.getLogger(__name__) diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index dc2db3bf32..30e7f70b02 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -8,7 +8,7 @@ namespace esphome::cover { -static const char *const TAG = "cover"; +ESPHOME_LOG_TAG(TAG, "cover"); const LogString *cover_command_to_str(float pos) { if (pos == COVER_OPEN) { diff --git a/esphome/components/cs5460a/__init__.py b/esphome/components/cs5460a/__init__.py index e69de29bb2..2ea88409a9 100644 --- a/esphome/components/cs5460a/__init__.py +++ b/esphome/components/cs5460a/__init__.py @@ -0,0 +1 @@ +DOMAIN = "cs5460a" diff --git a/esphome/components/cs5460a/cs5460a.cpp b/esphome/components/cs5460a/cs5460a.cpp index 1f9233841a..7c4e484791 100644 --- a/esphome/components/cs5460a/cs5460a.cpp +++ b/esphome/components/cs5460a/cs5460a.cpp @@ -3,7 +3,9 @@ namespace esphome::cs5460a { -static const char *const TAG = "cs5460a"; +ESPHOME_LOG_TAG(TAG, "cs5460a"); + +static constexpr uint32_t STATUS_CHECK_TIMEOUT_ID = 0; void CS5460AComponent::write_register_(enum CS5460ARegister addr, uint32_t value) { this->write_byte(CMD_WRITE | (addr << 1)); @@ -146,16 +148,16 @@ void CS5460AComponent::schedule_next_check_() { */ if (time_left > -15000) { time_left = 1000; - this->status_momentary_warning("warning", 1000); + this->status_momentary_warning(1000); } else { ESP_LOGCONFIG(TAG, "Device officially stuck, resetting"); - this->cancel_timeout("status-check"); + this->cancel_timeout(STATUS_CHECK_TIMEOUT_ID); this->hw_init_(); return; } } - this->set_timeout("status-check", time_left, [this]() { + this->set_timeout(STATUS_CHECK_TIMEOUT_ID, time_left, [this]() { if (!this->check_status_()) this->schedule_next_check_(); }); @@ -177,67 +179,67 @@ bool CS5460AComponent::check_status_() { if (status & (1 << 2)) { clear |= 1 << 2; ESP_LOGE(TAG, "Low supply detected"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 3)) { clear |= 1 << 3; ESP_LOGE(TAG, "Modulator oscillation on current channel"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 4)) { clear |= 1 << 4; ESP_LOGE(TAG, "Modulator oscillation on voltage channel"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 5)) { clear |= 1 << 5; ESP_LOGE(TAG, "Watch-dog timeout"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 11)) { clear |= 1 << 11; ESP_LOGE(TAG, "EOUT Energy Accumulation Register out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 12)) { clear |= 1 << 12; ESP_LOGE(TAG, "Energy out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 13)) { clear |= 1 << 13; ESP_LOGE(TAG, "RMS voltage out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 14)) { clear |= 1 << 14; ESP_LOGE(TAG, "RMS current out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 15)) { clear |= 1 << 15; ESP_LOGE(TAG, "Power calculation out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 16)) { clear |= 1 << 16; ESP_LOGE(TAG, "Voltage out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 17)) { clear |= 1 << 17; ESP_LOGE(TAG, "Current out of range"); - this->status_momentary_warning("warning", 500); + this->status_momentary_warning(500); } if (status & (1 << 19)) { 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/cse7761/__init__.py b/esphome/components/cse7761/__init__.py index e69de29bb2..3db7e9a1d4 100644 --- a/esphome/components/cse7761/__init__.py +++ b/esphome/components/cse7761/__init__.py @@ -0,0 +1 @@ +DOMAIN = "cse7761" diff --git a/esphome/components/cse7761/cse7761.cpp b/esphome/components/cse7761/cse7761.cpp index 103bc84452..4ba93af01f 100644 --- a/esphome/components/cse7761/cse7761.cpp +++ b/esphome/components/cse7761/cse7761.cpp @@ -4,7 +4,7 @@ namespace esphome::cse7761 { -static const char *const TAG = "cse7761"; +ESPHOME_LOG_TAG(TAG, "cse7761"); /*********************************************************************************************\ * CSE7761 - Energy (Sonoff Dual R3 Pow v1.x) @@ -18,21 +18,40 @@ static constexpr int CSE7761_UREF = 42563; // RmsUc static constexpr int CSE7761_IREF = 52241; // RmsIAC static constexpr int CSE7761_PREF = 44513; // PowerPAC +// Empirical Sonoff POW CT factor taken from Tasmota +static constexpr uint32_t CURRENT_TRANSFORMER_COEFFICIENT_SCALE = 5; + +// PGA 16x on both current channels +static constexpr uint16_t SYSCON_SHUNT = 0xFF04; +// All PGAs 1x, ADC2ON +static constexpr uint16_t SYSCON_CURRENT_TRANSFORMER = 0xFE00; + +// System clock (3.579545MHz) / 8, divided by Ufreq gives the line frequency in Hz +static constexpr float CSE7761_UFREQ_CLOCK = 3579545.0f / 8.0f; + static constexpr uint8_t CSE7761_REG_SYSCON = 0x00; // (2) System Control Register (0x0A04) static constexpr uint8_t CSE7761_REG_EMUCON = 0x01; // (2) Metering control register (0x0000) +static constexpr uint8_t CSE7761_REG_HFCONST = 0x02; // (2) Pulse frequency register (0x1000) static constexpr uint8_t CSE7761_REG_EMUCON2 = 0x13; // (2) Metering control register 2 (0x0001) static constexpr uint8_t CSE7761_REG_PULSE1SEL = 0x1D; // (2) Pin function output select register (0x3210) -static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) -static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) -static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) -static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register +static constexpr uint8_t CSE7761_REG_UFREQ = 0x23; // (2) Voltage frequency (0x0000) +static constexpr uint8_t CSE7761_REG_RMSIA = 0x24; // (3) The effective value of channel A current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSIB = 0x25; // (3) The effective value of channel B current (0x000000) +static constexpr uint8_t CSE7761_REG_RMSU = 0x26; // (3) Voltage RMS (0x000000) +static constexpr uint8_t CSE7761_REG_POWERFACTOR = 0x27; // (3) Power factor of the selected channel (0x7FFFFF) +static constexpr uint8_t CSE7761_REG_ENERGY_PA = 0x28; // (3) Channel A active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_ENERGY_PB = 0x29; // (3) Channel B active energy pulse count (0x000000) +static constexpr uint8_t CSE7761_REG_POWERPA = 0x2C; // (4) Channel A active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_POWERPB = 0x2D; // (4) Channel B active power, update rate 27.2Hz (0x00000000) +static constexpr uint8_t CSE7761_REG_SYSSTATUS = 0x43; // (1) System status register static constexpr uint8_t CSE7761_REG_COEFFCHKSUM = 0x6F; // (2) Coefficient checksum static constexpr uint8_t CSE7761_REG_RMSIAC = 0x70; // (2) Channel A effective current conversion coefficient +// Datasheet: Wh = pulses * EnergyXC * HFConst * 1000 / 2^41 with K1 = K2 = 1 +static constexpr float CSE7761_ENERGY_WH_SCALE = 1000.0f / 2199023255552.0f; + static constexpr uint8_t CSE7761_SPECIAL_COMMAND = 0xEA; // Start special command static constexpr uint8_t CSE7761_CMD_RESET = 0x96; // Reset command, after receiving the command, the chip resets static constexpr uint8_t CSE7761_CMD_CLOSE_WRITE = 0xDC; // Close write operation @@ -57,7 +76,18 @@ void CSE7761Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } + ESP_LOGCONFIG(TAG, " Current input: %s", + LOG_STR_ARG(this->is_current_transformer_() ? LOG_STR("current transformer") : LOG_STR("shunt"))); LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current 1", this->current_sensor_1_); + LOG_SENSOR(" ", "Current 2", this->current_sensor_2_); + LOG_SENSOR(" ", "Power 1", this->power_sensor_1_); + LOG_SENSOR(" ", "Power 2", this->power_sensor_2_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Power Factor 1", this->power_factor_sensor_1_); + LOG_SENSOR(" ", "Energy 1", this->energy_sensors_[0]); + LOG_SENSOR(" ", "Energy 2", this->energy_sensors_[1]); } void CSE7761Component::update() { @@ -73,7 +103,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { buffer[1] = reg; uint32_t len = 2; if (data) { - if (data < 0xFF) { + if (data <= 0xFF) { buffer[2] = data & 0xFF; len = 3; } else { @@ -81,11 +111,7 @@ void CSE7761Component::write_(uint8_t reg, uint16_t data) { buffer[3] = data & 0xFF; len = 4; } - uint8_t crc = 0; - for (uint32_t i = 0; i < len; i++) { - crc += buffer[i]; - } - buffer[len] = ~crc; + buffer[len] = ~sum_bytes(buffer, len); len++; } @@ -115,46 +141,44 @@ bool CSE7761Component::read_once_(uint8_t reg, uint8_t size, uint32_t *value) { } rcvd--; + if (static_cast(~sum_bytes(buffer, rcvd, 0xA5 + reg)) != buffer[rcvd]) { + return false; + } uint32_t result = 0; - // CRC check - uint8_t crc = 0xA5 + reg; for (uint32_t i = 0; i < rcvd; i++) { result = (result << 8) | buffer[i]; - crc += buffer[i]; - } - crc = ~crc; - if (crc != buffer[rcvd]) { - return false; } *value = result; return true; } -uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { - bool result = false; // Start loop - uint8_t retry = 3; // Retry up to three times - uint32_t value = 0; // Default no value - while (!result && retry > 0) { - retry--; - if (this->read_once_(reg, size, &value)) - return value; +bool CSE7761Component::read_(uint8_t reg, uint8_t size, uint32_t *value) { + for (uint8_t retry = 0; retry < 3; retry++) { + if (this->read_once_(reg, size, value)) + return true; } ESP_LOGE(TAG, "Reading register %hhu failed!", reg); - return value; + return false; +} + +uint32_t CSE7761Component::read_(uint8_t reg, uint8_t size) { + uint32_t value = 0; + return this->read_(reg, size, &value) ? value : 0; } uint32_t CSE7761Component::coefficient_by_unit_(uint32_t unit) { + const uint32_t scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1; uint32_t coeff = 0; switch (unit) { case RMS_UC: coeff = this->data_.coefficient[RMS_UC]; return coeff ? 0x400000 * 100 / coeff : 0; case RMS_IAC: - coeff = this->data_.coefficient[RMS_IAC]; + coeff = this->data_.coefficient[RMS_IAC] * scale; return coeff ? (0x800000 * 100 / coeff) * 10 : 0; // Stay within 32 bits case POWER_PAC: - coeff = this->data_.coefficient[POWER_PAC]; + coeff = this->data_.coefficient[POWER_PAC] * scale; return coeff ? 0x80000000 / coeff : 0; } return 0; @@ -167,21 +191,38 @@ bool CSE7761Component::chip_init_() { calc_chksum += this->data_.coefficient[i]; } calc_chksum = ~calc_chksum; + const bool energy_configured = this->energy_sensors_[0] != nullptr || this->energy_sensors_[1] != nullptr; uint16_t coeff_chksum = this->read_(CSE7761_REG_COEFFCHKSUM, 2); if ((calc_chksum != coeff_chksum) || (!calc_chksum)) { ESP_LOGD(TAG, "Default calibration"); this->data_.coefficient[RMS_IAC] = CSE7761_IREF; this->data_.coefficient[RMS_UC] = CSE7761_UREF; this->data_.coefficient[POWER_PAC] = CSE7761_PREF; + if (energy_configured) { + ESP_LOGW(TAG, "No energy calibration, energy unavailable"); + } + } else if (energy_configured) { + uint32_t hf_const = 0; + if (!this->read_(CSE7761_REG_HFCONST, 2, &hf_const)) { + ESP_LOGW(TAG, "HFConst read failed, energy unavailable"); + } + // The pulse counters follow the raw power, so they need the same front end factor as power + const float input_scale = this->is_current_transformer_() ? CURRENT_TRANSFORMER_COEFFICIENT_SCALE : 1.0f; + for (uint8_t channel = 0; channel < 2; channel++) { + uint32_t scale = this->data_.coefficient[ENERGY_AC + channel] * hf_const; + this->energy_[channel].wh_per_pulse = static_cast(scale) * CSE7761_ENERGY_WH_SCALE * input_scale; + } } this->write_(CSE7761_SPECIAL_COMMAND, CSE7761_CMD_ENABLE_WRITE); uint8_t sys_status = this->read_(CSE7761_REG_SYSSTATUS, 1); if (sys_status & 0x10) { // Write enable to protected registers (WREN) - this->write_(CSE7761_REG_SYSCON | 0x80, 0xFF04); + this->write_(CSE7761_REG_SYSCON | 0x80, + this->is_current_transformer_() ? SYSCON_CURRENT_TRANSFORMER : SYSCON_SHUNT); this->write_(CSE7761_REG_EMUCON | 0x80, 0x1183); - this->write_(CSE7761_REG_EMUCON2 | 0x80, 0x0FC1); + // WaveEN|ZxEN, needed for Ufreq + this->write_(CSE7761_REG_EMUCON2 | 0x80, this->frequency_sensor_ != nullptr ? 0x0FE5 : 0x0FC1); this->write_(CSE7761_REG_PULSE1SEL | 0x80, 0x3290); } else { ESP_LOGD(TAG, "Write failed at chip_init"); @@ -199,14 +240,15 @@ void CSE7761Component::get_data_() { uint32_t value = this->read_(CSE7761_REG_RMSU, 3); this->data_.voltage_rms = (value >= 0x800000) ? 0 : value; + // No load threshold of 10mA with a shunt; the raw value stands for 5 times more with a current transformer value = this->read_(CSE7761_REG_RMSIA, 3); - this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA + this->data_.current_rms[0] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; value = this->read_(CSE7761_REG_POWERPA, 4); // PowerPA is two's complement signed 32-bit per datasheet this->data_.active_power[0] = (0 == this->data_.current_rms[0]) ? 0 : static_cast(value); value = this->read_(CSE7761_REG_RMSIB, 3); - this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; // No load threshold of 10mA + this->data_.current_rms[1] = ((value >= 0x800000) || (value < 1600)) ? 0 : value; value = this->read_(CSE7761_REG_POWERPB, 4); // PowerPB is two's complement signed 32-bit per datasheet this->data_.active_power[1] = (0 == this->data_.current_rms[1]) ? 0 : static_cast(value); @@ -218,12 +260,35 @@ void CSE7761Component::get_data_() { this->voltage_sensor_->publish_state(voltage); } + uint32_t raw_reg = 0; + if (this->frequency_sensor_ != nullptr && this->read_(CSE7761_REG_UFREQ, 2, &raw_reg)) { + // Ufreq highest bit set means no signal + float frequency = (raw_reg == 0 || raw_reg >= 0x8000) ? 0.0f : CSE7761_UFREQ_CLOCK / raw_reg; + this->frequency_sensor_->publish_state(frequency); + } + + if (this->power_factor_sensor_1_ != nullptr && this->read_(CSE7761_REG_POWERFACTOR, 3, &raw_reg)) { + // ADC2ON makes the chip report channel A + int24_t pf{static_cast(raw_reg)}; + this->power_factor_sensor_1_->publish_state(static_cast(static_cast(pf)) / 0x800000); + } + for (uint8_t channel = 0; channel < 2; channel++) { // Active power = PowerPA * PowerPAC * 1000 / 0x80000000 float active_power = static_cast(this->data_.active_power[channel]) / this->coefficient_by_unit_(POWER_PAC); // W float amps = static_cast(this->data_.current_rms[channel]) / this->coefficient_by_unit_(RMS_IAC); // A ESP_LOGD(TAG, "Channel %d power %f W, current %f A", channel + 1, active_power, amps); + sensor::Sensor *energy_sensor = this->energy_sensors_[channel]; + CSE7761EnergyChannel &energy = this->energy_[channel]; + uint8_t energy_reg = (channel == 0) ? CSE7761_REG_ENERGY_PA : CSE7761_REG_ENERGY_PB; + uint32_t raw = 0; + if (energy_sensor != nullptr && energy.wh_per_pulse > 0.0f && this->read_(energy_reg, 3, &raw)) { + // 24-bit counter, not cleared on read + energy.pulses += (raw - energy.last_pulses) & 0xFFFFFF; + energy.last_pulses = raw; + energy_sensor->publish_state(energy.pulses * energy.wh_per_pulse); + } if (channel == 0) { if (this->power_sensor_1_ != nullptr) { this->power_sensor_1_->publish_state(active_power); diff --git a/esphome/components/cse7761/cse7761.h b/esphome/components/cse7761/cse7761.h index e08ebf09cc..fc4eac6477 100644 --- a/esphome/components/cse7761/cse7761.h +++ b/esphome/components/cse7761/cse7761.h @@ -3,11 +3,11 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/uart/uart.h" #include "esphome/core/component.h" +#include "esphome/core/datatypes.h" namespace esphome::cse7761 { struct CSE7761DataStruct { - uint32_t frequency = 0; uint32_t voltage_rms = 0; uint32_t current_rms[2] = {0}; int32_t active_power[2] = {0}; @@ -15,14 +15,32 @@ struct CSE7761DataStruct { bool ready = false; }; +struct CSE7761EnergyChannel { + uint32_t last_pulses{0}; + uint32_t pulses{0}; + float wh_per_pulse{0.0f}; +}; + +enum class CSE7761CurrentInput : uint8_t { + CSE7761_CURRENT_INPUT_SHUNT, + CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER, +}; + /// This class implements support for the CSE7761 UART power sensor. class CSE7761Component final : public PollingComponent, public uart::UARTDevice { public: + explicit CSE7761Component(CSE7761CurrentInput current_input) : current_input_(current_input) {} void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; } + void set_frequency_sensor(sensor::Sensor *frequency_sensor) { frequency_sensor_ = frequency_sensor; } + void set_power_factor_1_sensor(sensor::Sensor *power_factor_sensor_1) { + power_factor_sensor_1_ = power_factor_sensor_1; + } void set_active_power_1_sensor(sensor::Sensor *power_sensor_1) { power_sensor_1_ = power_sensor_1; } void set_current_1_sensor(sensor::Sensor *current_sensor_1) { current_sensor_1_ = current_sensor_1; } + void set_energy_1_sensor(sensor::Sensor *energy_sensor_1) { this->energy_sensors_[0] = energy_sensor_1; } void set_active_power_2_sensor(sensor::Sensor *power_sensor_2) { power_sensor_2_ = power_sensor_2; } void set_current_2_sensor(sensor::Sensor *current_sensor_2) { current_sensor_2_ = current_sensor_2; } + void set_energy_2_sensor(sensor::Sensor *energy_sensor_2) { this->energy_sensors_[1] = energy_sensor_2; } void setup() override; void dump_config() override; void update() override; @@ -30,14 +48,23 @@ class CSE7761Component final : public PollingComponent, public uart::UARTDevice protected: // Sensors sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *frequency_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_1_{nullptr}; sensor::Sensor *power_sensor_1_{nullptr}; sensor::Sensor *current_sensor_1_{nullptr}; sensor::Sensor *power_sensor_2_{nullptr}; sensor::Sensor *current_sensor_2_{nullptr}; + sensor::Sensor *energy_sensors_[2]{nullptr, nullptr}; CSE7761DataStruct data_; + CSE7761EnergyChannel energy_[2]; + const CSE7761CurrentInput current_input_; + bool is_current_transformer_() const { + return this->current_input_ == CSE7761CurrentInput::CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER; + } void write_(uint8_t reg, uint16_t data); bool read_once_(uint8_t reg, uint8_t size, uint32_t *value); + bool read_(uint8_t reg, uint8_t size, uint32_t *value); uint32_t read_(uint8_t reg, uint8_t size); uint32_t coefficient_by_unit_(uint32_t unit); bool chip_init_(); diff --git a/esphome/components/cse7761/sensor.py b/esphome/components/cse7761/sensor.py index 5f79be0255..6f695307de 100644 --- a/esphome/components/cse7761/sensor.py +++ b/esphome/components/cse7761/sensor.py @@ -2,15 +2,22 @@ import esphome.codegen as cg from esphome.components import sensor, uart import esphome.config_validation as cv from esphome.const import ( + CONF_FREQUENCY, CONF_ID, CONF_VOLTAGE, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, + UNIT_HERTZ, UNIT_VOLT, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -26,17 +33,41 @@ CONF_CURRENT_1 = "current_1" CONF_CURRENT_2 = "current_2" CONF_ACTIVE_POWER_1 = "active_power_1" CONF_ACTIVE_POWER_2 = "active_power_2" +CONF_ENERGY_1 = "energy_1" +CONF_ENERGY_2 = "energy_2" +CONF_POWER_FACTOR_1 = "power_factor_1" +CONF_CURRENT_INPUT = "current_input" + +CSE7761CurrentInput = cse7761_ns.enum("CSE7761CurrentInput", is_class=True) +CURRENT_INPUTS = { + "shunt": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_SHUNT, + "current_transformer": CSE7761CurrentInput.CSE7761_CURRENT_INPUT_CURRENT_TRANSFORMER, +} CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(CSE7761Component), + cv.Optional(CONF_CURRENT_INPUT, default="shunt"): cv.enum( + CURRENT_INPUTS, lower=True + ), cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, accuracy_decimals=1, device_class=DEVICE_CLASS_VOLTAGE, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_FREQUENCY): sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR_1): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional(CONF_CURRENT_1): sensor.sensor_schema( unit_of_measurement=UNIT_AMPERE, accuracy_decimals=2, @@ -61,6 +92,18 @@ CONFIG_SCHEMA = ( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + cv.Optional(CONF_ENERGY_1): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_ENERGY_2): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } ) .extend(cv.polling_component_schema("60s")) @@ -79,19 +122,17 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( async def to_code(config: ConfigType) -> None: - var = cg.new_Pvariable(config[CONF_ID]) + var = cg.new_Pvariable(config[CONF_ID], config[CONF_CURRENT_INPUT]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - for key in [ - CONF_VOLTAGE, - CONF_CURRENT_1, - CONF_CURRENT_2, - CONF_ACTIVE_POWER_1, - CONF_ACTIVE_POWER_2, - ]: - if key not in config: - continue - conf = config[key] - sens = await sensor.new_sensor(conf) - cg.add(getattr(var, f"set_{key}_sensor")(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_VOLTAGE, var.set_voltage_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_POWER_FACTOR_1, var.set_power_factor_1_sensor) + await sensors(CONF_CURRENT_1, var.set_current_1_sensor) + await sensors(CONF_CURRENT_2, var.set_current_2_sensor) + await sensors(CONF_ACTIVE_POWER_1, var.set_active_power_1_sensor) + await sensors(CONF_ACTIVE_POWER_2, var.set_active_power_2_sensor) + await sensors(CONF_ENERGY_1, var.set_energy_1_sensor) + await sensors(CONF_ENERGY_2, var.set_energy_2_sensor) diff --git a/esphome/components/cse7766/__init__.py b/esphome/components/cse7766/__init__.py index e69de29bb2..df45f6395c 100644 --- a/esphome/components/cse7766/__init__.py +++ b/esphome/components/cse7766/__init__.py @@ -0,0 +1 @@ +DOMAIN = "cse7766" diff --git a/esphome/components/cse7766/cse7766.cpp b/esphome/components/cse7766/cse7766.cpp index 30f1b7a867..bbe76855fb 100644 --- a/esphome/components/cse7766/cse7766.cpp +++ b/esphome/components/cse7766/cse7766.cpp @@ -6,7 +6,7 @@ namespace esphome::cse7766 { -static const char *const TAG = "cse7766"; +ESPHOME_LOG_TAG(TAG, "cse7766"); void CSE7766Component::loop() { const uint32_t now = App.get_loop_component_start_time(); @@ -67,10 +67,7 @@ bool CSE7766Component::check_byte_() { } if (index == CSE7766_RAW_DATA_SIZE - 1) { - uint8_t checksum = 0; - for (uint8_t i = 2; i < CSE7766_RAW_DATA_SIZE - 1; i++) { - checksum += this->raw_data_[i]; - } + const uint8_t checksum = sum_bytes(this->raw_data_ + 2, CSE7766_RAW_DATA_SIZE - 3); if (checksum != this->raw_data_[CSE7766_RAW_DATA_SIZE - 1]) { ESP_LOGW(TAG, "Invalid checksum from CSE7766: 0x%02X != 0x%02X", checksum, @@ -205,9 +202,10 @@ void CSE7766Component::parse_data_() { float pf = NAN; if (apparent_power > 0) { pf = power / apparent_power; - if (pf < 0 || pf > 1) { - ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf); - pf = NAN; + if (pf > 1) { + // Noise near the chip's resolution limit, seen during inrush, pushes the ratio past unity + ESP_LOGD(TAG, "Power factor %.4f above 1, clamped", pf); + pf = 1.0f; } } else if (apparent_power == 0 && power == 0) { // No load, report ideal power factor 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/cst226/__init__.py b/esphome/components/cst226/__init__.py index 847e44dbda..5bccdc1cba 100644 --- a/esphome/components/cst226/__init__.py +++ b/esphome/components/cst226/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "cst226" cst226_ns = cg.esphome_ns.namespace("cst226") diff --git a/esphome/components/cst226/binary_sensor/cstt6_button.cpp b/esphome/components/cst226/binary_sensor/cstt6_button.cpp index c14da2b176..a755c3d09c 100644 --- a/esphome/components/cst226/binary_sensor/cstt6_button.cpp +++ b/esphome/components/cst226/binary_sensor/cstt6_button.cpp @@ -3,7 +3,7 @@ namespace esphome::cst226 { -static const char *const TAG = "CST226.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "CST226.binary_sensor"); void CST226Button::setup() { this->parent_->register_button_listener(this); diff --git a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp index 49d61cbfbc..849cff4a49 100644 --- a/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp +++ b/esphome/components/cst226/touchscreen/cst226_touchscreen.cpp @@ -2,7 +2,7 @@ namespace esphome::cst226 { -static const char *const TAG = "cst226.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst226.touchscreen"); void CST226Touchscreen::setup() { if (this->reset_pin_ != nullptr) { diff --git a/esphome/components/cst328/__init__.py b/esphome/components/cst328/__init__.py index 374df64898..6b10398969 100644 --- a/esphome/components/cst328/__init__.py +++ b/esphome/components/cst328/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] +DOMAIN = "cst328" cst328_ns = cg.esphome_ns.namespace("cst328") diff --git a/esphome/components/cst328/binary_sensor/cst328_button.cpp b/esphome/components/cst328/binary_sensor/cst328_button.cpp index b58f4b4b9f..2010f09751 100644 --- a/esphome/components/cst328/binary_sensor/cst328_button.cpp +++ b/esphome/components/cst328/binary_sensor/cst328_button.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" namespace esphome::cst328 { -static const char *const TAG = "cst328.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "cst328.binary_sensor"); void CST328Button::setup() { this->parent_->register_button_listener(this); diff --git a/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp index 5e1a2ebf72..946eab1b17 100644 --- a/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp +++ b/esphome/components/cst328/touchscreen/cst328_touchscreen.cpp @@ -3,7 +3,7 @@ namespace esphome::cst328 { -static const char *const TAG = "cst328.touchscreen"; +ESPHOME_LOG_TAG(TAG, "cst328.touchscreen"); static const uint32_t CST328_BEFORE_RESET_TIMEOUT = 50; // 50 ms from datasheet static const uint32_t CST328_TRANSITION_TIMEOUT = 300; // 200 ms from datasheet, but typically much less diff --git a/esphome/components/cst816/__init__.py b/esphome/components/cst816/__init__.py index 674a80b7c1..8d6e15abc5 100644 --- a/esphome/components/cst816/__init__.py +++ b/esphome/components/cst816/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "cst816" cst816_ns = cg.esphome_ns.namespace("cst816") diff --git a/esphome/components/cst9220/__init__.py b/esphome/components/cst9220/__init__.py index f97c8944ef..e3de3243e0 100644 --- a/esphome/components/cst9220/__init__.py +++ b/esphome/components/cst9220/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "cst9220" cst9220_ns = cg.esphome_ns.namespace("cst9220") diff --git a/esphome/components/ct_clamp/__init__.py b/esphome/components/ct_clamp/__init__.py index e69de29bb2..f7ab2905b2 100644 --- a/esphome/components/ct_clamp/__init__.py +++ b/esphome/components/ct_clamp/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ct_clamp" diff --git a/esphome/components/ct_clamp/ct_clamp_sensor.cpp b/esphome/components/ct_clamp/ct_clamp_sensor.cpp index 613c3428be..6488c468c3 100644 --- a/esphome/components/ct_clamp/ct_clamp_sensor.cpp +++ b/esphome/components/ct_clamp/ct_clamp_sensor.cpp @@ -6,7 +6,9 @@ namespace esphome::ct_clamp { -static const char *const TAG = "ct_clamp"; +ESPHOME_LOG_TAG(TAG, "ct_clamp"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; void CTClampSensor::dump_config() { LOG_SENSOR("", "CT Clamp Sensor", this); @@ -21,7 +23,7 @@ void CTClampSensor::update() { this->high_freq_.start(); // Set timeout for ending sampling phase - this->set_timeout("read", this->sample_duration_, [this]() { + this->set_timeout(READ_TIMEOUT_ID, this->sample_duration_, [this]() { this->is_sampling_ = false; this->high_freq_.stop(); diff --git a/esphome/components/current_based/__init__.py b/esphome/components/current_based/__init__.py index e7f41154b7..0ef871eeed 100644 --- a/esphome/components/current_based/__init__.py +++ b/esphome/components/current_based/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@djwmarcx"] +DOMAIN = "current_based" diff --git a/esphome/components/current_based/current_based_cover.cpp b/esphome/components/current_based/current_based_cover.cpp index d15b310a37..0474397571 100644 --- a/esphome/components/current_based/current_based_cover.cpp +++ b/esphome/components/current_based/current_based_cover.cpp @@ -6,7 +6,9 @@ namespace esphome::current_based { -static const char *const TAG = "current_based.cover"; +ESPHOME_LOG_TAG(TAG, "current_based.cover"); + +static constexpr uint32_t ROLLBACK_TIMEOUT_ID = 0; using namespace esphome::cover; @@ -73,7 +75,7 @@ void CurrentBasedCover::loop() { ESP_LOGD(TAG, "'%s' - Obstacle detected during opening.", this->name_.c_str()); this->direction_idle_(); if (this->obstacle_rollback_ != 0) { - this->set_timeout("rollback", 300, [this]() { + this->set_timeout(ROLLBACK_TIMEOUT_ID, 300, [this]() { ESP_LOGD(TAG, "'%s' - Rollback.", this->name_.c_str()); this->target_position_ = clamp(this->position - this->obstacle_rollback_, 0.0F, 1.0F); this->start_direction_(COVER_OPERATION_CLOSING); @@ -94,7 +96,7 @@ void CurrentBasedCover::loop() { ESP_LOGD(TAG, "'%s' - Obstacle detected during closing.", this->name_.c_str()); this->direction_idle_(); if (this->obstacle_rollback_ != 0) { - this->set_timeout("rollback", 300, [this]() { + this->set_timeout(ROLLBACK_TIMEOUT_ID, 300, [this]() { ESP_LOGD(TAG, "'%s' - Rollback.", this->name_.c_str()); this->target_position_ = clamp(this->position + this->obstacle_rollback_, 0.0F, 1.0F); this->start_direction_(COVER_OPERATION_OPENING); diff --git a/esphome/components/custom/__init__.py b/esphome/components/custom/__init__.py index e69de29bb2..4eb4f32c12 100644 --- a/esphome/components/custom/__init__.py +++ b/esphome/components/custom/__init__.py @@ -0,0 +1 @@ +DOMAIN = "custom" diff --git a/esphome/components/custom_component/__init__.py b/esphome/components/custom_component/__init__.py index 982153414d..395f140d6c 100644 --- a/esphome/components/custom_component/__init__.py +++ b/esphome/components/custom_component/__init__.py @@ -1,5 +1,6 @@ import esphome.config_validation as cv +DOMAIN = "custom_component" MULTI_CONF = True CONFIG_SCHEMA = cv.invalid( diff --git a/esphome/components/cwww/__init__.py b/esphome/components/cwww/__init__.py index e69de29bb2..2da0e50fa0 100644 --- a/esphome/components/cwww/__init__.py +++ b/esphome/components/cwww/__init__.py @@ -0,0 +1 @@ +DOMAIN = "cwww" diff --git a/esphome/components/d01/__init__.py b/esphome/components/d01/__init__.py index e69de29bb2..45595aada3 100644 --- a/esphome/components/d01/__init__.py +++ b/esphome/components/d01/__init__.py @@ -0,0 +1 @@ +DOMAIN = "d01" diff --git a/esphome/components/d01/d01.cpp b/esphome/components/d01/d01.cpp index f7a0c08ec4..e15a5959cb 100644 --- a/esphome/components/d01/d01.cpp +++ b/esphome/components/d01/d01.cpp @@ -17,7 +17,7 @@ namespace esphome::d01 { -static const char *const TAG = "d01"; +ESPHOME_LOG_TAG(TAG, "d01"); static const uint8_t D01_FRAME_HEADER = 0xA5; diff --git a/esphome/components/dac7678/__init__.py b/esphome/components/dac7678/__init__.py index 668cc87cec..4ee9f1f541 100644 --- a/esphome/components/dac7678/__init__.py +++ b/esphome/components/dac7678/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["output"] CODEOWNERS = ["@NickB1"] DEPENDENCIES = ["i2c"] +DOMAIN = "dac7678" MULTI_CONF = True dac7678_ns = cg.esphome_ns.namespace("dac7678") diff --git a/esphome/components/dac7678/dac7678_output.cpp b/esphome/components/dac7678/dac7678_output.cpp index 15575583d9..d1a076733a 100644 --- a/esphome/components/dac7678/dac7678_output.cpp +++ b/esphome/components/dac7678/dac7678_output.cpp @@ -5,7 +5,7 @@ namespace esphome::dac7678 { -static const char *const TAG = "dac7678"; +ESPHOME_LOG_TAG(TAG, "dac7678"); static const uint8_t DAC7678_REG_INPUT_N = 0x00; static const uint8_t DAC7678_REG_SELECT_UPDATE_N = 0x10; diff --git a/esphome/components/daikin/__init__.py b/esphome/components/daikin/__init__.py index e69de29bb2..cc5617d4d5 100644 --- a/esphome/components/daikin/__init__.py +++ b/esphome/components/daikin/__init__.py @@ -0,0 +1 @@ +DOMAIN = "daikin" diff --git a/esphome/components/daikin/daikin.cpp b/esphome/components/daikin/daikin.cpp index d45586ba2d..370c04fbf6 100644 --- a/esphome/components/daikin/daikin.cpp +++ b/esphome/components/daikin/daikin.cpp @@ -1,25 +1,40 @@ #include "daikin.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::daikin { -static const char *const TAG = "daikin.climate"; +ESPHOME_LOG_TAG(TAG, "daikin.climate"); + +static constexpr uint32_t BOOST_TIMEOUT_ID = 0; + +void DaikinClimate::setup() { + climate_ir::ClimateIR::setup(); + // A boost preset restored from flash gets its timer too + this->update_boost_timer_(false); +} + +void DaikinClimate::control(const climate::ClimateCall &call) { + const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST; + climate_ir::ClimateIR::control(call); + this->update_boost_timer_(was_boost); +} void DaikinClimate::transmit_state() { - uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0xD7, 0x11, 0xDA, 0x27, 0x00, + uint8_t remote_state[35] = {0x11, 0xDA, 0x27, 0x00, 0xC5, 0x00, 0x00, 0x00, 0x11, 0xDA, 0x27, 0x00, 0x42, 0x49, 0x05, 0xA2, 0x11, 0xDA, 0x27, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x60, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00}; + this->set_presets_(remote_state); remote_state[21] = this->operation_mode_(); remote_state[22] = this->temperature_(); uint16_t fan_speed = this->fan_speed_(); remote_state[24] = fan_speed >> 8; remote_state[25] = fan_speed & 0xff; - // Calculate checksum - for (int i = 16; i < 34; i++) { - remote_state[34] += remote_state[i]; - } + remote_state[7] = sum_bytes(remote_state, 7); + remote_state[34] = sum_bytes(remote_state + 16, 18); auto transmit = this->transmitter_->transmit(); auto *data = transmit.get_data(); @@ -136,18 +151,52 @@ uint8_t DaikinClimate::temperature_() const { case climate::CLIMATE_MODE_DRY: return 0xc0; default: - uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX)); - return temperature << 1; + return static_cast( + roundf(clamp(this->target_temperature, DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX) * 2.0f)); } } -bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; +void DaikinClimate::set_presets_(uint8_t *remote_state) const { + switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { + case climate::CLIMATE_PRESET_BOOST: + remote_state[29] = DAIKIN_PRESET_POWERFUL_ON; + break; + case climate::CLIMATE_PRESET_SLEEP: + remote_state[29] = DAIKIN_PRESET_QUIET_ON; + break; + case climate::CLIMATE_PRESET_ECO: + remote_state[32] = DAIKIN_PRESET_ECONO_ON; + break; + case climate::CLIMATE_PRESET_ACTIVITY: + remote_state[32] = DAIKIN_PRESET_SENSOR_ON; + break; + case climate::CLIMATE_PRESET_COMFORT: + remote_state[6] = DAIKIN_PRESET_COMFORT_ON; // the only preset carried by the first frame + break; + default: + break; } - if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) - return false; +} + +void DaikinClimate::update_boost_timer_(bool was_boost) { + const bool is_boost = this->preset == climate::CLIMATE_PRESET_BOOST; + if (is_boost && !was_boost) { + this->set_timeout(BOOST_TIMEOUT_ID, DAIKIN_BOOST_DURATION_MS, [this]() { this->cancel_boost_mode_(); }); + } else if (was_boost && !is_boost) { + this->cancel_timeout(BOOST_TIMEOUT_ID); + } +} + +void DaikinClimate::cancel_boost_mode_() { + if (this->preset != climate::CLIMATE_PRESET_BOOST) + return; + ESP_LOGD(TAG, "Boost ended"); + this->preset = climate::CLIMATE_PRESET_NONE; + this->publish_state(); +} + +bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { + const bool was_boost = this->preset == climate::CLIMATE_PRESET_BOOST; uint8_t mode = frame[5]; // Temperature is given in degrees celcius * 2 // only update for states that use the temperature @@ -206,16 +255,42 @@ bool DaikinClimate::parse_state_frame_(const uint8_t frame[]) { this->fan_mode = climate::CLIMATE_FAN_QUIET; break; } + + // Comfort lives in the first frame, recorded as pending until this frame proves the transmission valid + // A comfort frame whose own state frame never came must not attach to a later transmission + const uint32_t now = App.get_loop_component_start_time(); + const PendingComfort comfort = now - this->pending_comfort_at_ <= DAIKIN_TRANSMISSION_MS + ? this->pending_comfort_ + : PendingComfort::PENDING_COMFORT_UNKNOWN; + this->pending_comfort_ = PendingComfort::PENDING_COMFORT_UNKNOWN; + if (frame[13] & DAIKIN_PRESET_POWERFUL_ON) { + this->preset = climate::CLIMATE_PRESET_BOOST; + } else if (frame[13] & DAIKIN_PRESET_QUIET_ON) { + this->preset = climate::CLIMATE_PRESET_SLEEP; + } else if (frame[16] & DAIKIN_PRESET_ECONO_ON) { + this->preset = climate::CLIMATE_PRESET_ECO; + } else if (frame[16] & DAIKIN_PRESET_SENSOR_ON) { + this->preset = climate::CLIMATE_PRESET_ACTIVITY; + } else if (comfort == PendingComfort::PENDING_COMFORT_ON) { + this->preset = climate::CLIMATE_PRESET_COMFORT; + } else if (comfort == PendingComfort::PENDING_COMFORT_OFF || this->preset != climate::CLIMATE_PRESET_COMFORT) { + // Without a comfort frame in this transmission a current comfort preset is left alone + this->preset = climate::CLIMATE_PRESET_NONE; + } + this->update_boost_timer_(was_boost); + this->publish_state(); return true; } bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { - uint8_t state_frame[DAIKIN_STATE_FRAME_SIZE] = {}; + uint8_t frame[DAIKIN_STATE_FRAME_SIZE] = {}; if (!data.expect_item(DAIKIN_HEADER_MARK, DAIKIN_HEADER_SPACE)) { return false; } - for (uint8_t pos = 0; pos < DAIKIN_STATE_FRAME_SIZE; pos++) { + + uint8_t frame_size = DAIKIN_STATE_FRAME_SIZE; + for (uint8_t pos = 0; pos < frame_size; pos++) { uint8_t byte = 0; for (int8_t bit = 0; bit < 8; bit++) { if (data.expect_item(DAIKIN_BIT_MARK, DAIKIN_ONE_SPACE)) { @@ -224,7 +299,7 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } } - state_frame[pos] = byte; + frame[pos] = byte; if (pos == 0) { // frame header if (byte != 0x11) @@ -243,11 +318,25 @@ bool DaikinClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } else if (pos == 4) { // frame type - if (byte != 0x00) + if (byte == DAIKIN_FRAME_TYPE_COMFORT) { + frame_size = DAIKIN_COMFORT_FRAME_SIZE; + } else if (byte != DAIKIN_FRAME_TYPE_STATE) { return false; + } } } - return this->parse_state_frame_(state_frame); + if (frame[frame_size - 1] != sum_bytes(frame, frame_size - 1)) { + return false; + } + + if (frame_size == DAIKIN_COMFORT_FRAME_SIZE) { + // Nothing changes yet: the state frame that follows applies it once it has passed its own checksum + this->pending_comfort_ = (frame[6] & DAIKIN_PRESET_COMFORT_ON) ? PendingComfort::PENDING_COMFORT_ON + : PendingComfort::PENDING_COMFORT_OFF; + this->pending_comfort_at_ = App.get_loop_component_start_time(); + return true; + } + return this->parse_state_frame_(frame); } } // namespace esphome::daikin diff --git a/esphome/components/daikin/daikin.h b/esphome/components/daikin/daikin.h index b7fa992307..3124ba0145 100644 --- a/esphome/components/daikin/daikin.h +++ b/esphome/components/daikin/daikin.h @@ -27,6 +27,17 @@ const uint8_t DAIKIN_FAN_3 = 0x50; const uint8_t DAIKIN_FAN_4 = 0x60; const uint8_t DAIKIN_FAN_5 = 0x70; +// Presets: bit flags in byte 6 (comfort), byte 29 (powerful, quiet) and byte 32 (sensor, econo) +const uint8_t DAIKIN_PRESET_COMFORT_ON = 0x10; +const uint8_t DAIKIN_PRESET_POWERFUL_ON = 0x01; +const uint8_t DAIKIN_PRESET_QUIET_ON = 0x20; +const uint8_t DAIKIN_PRESET_ECONO_ON = 0x04; +const uint8_t DAIKIN_PRESET_SENSOR_ON = 0x02; +// The unit leaves powerful mode by itself after 20 minutes; mirror that in the entity +const uint32_t DAIKIN_BOOST_DURATION_MS = 20 * 60 * 1000; +// The three frames of one transmission arrive well inside this window +const uint32_t DAIKIN_TRANSMISSION_MS = 1000; + // IR Transmission const uint32_t DAIKIN_IR_FREQUENCY = 38000; const uint32_t DAIKIN_HEADER_MARK = 3360; @@ -36,27 +47,44 @@ const uint32_t DAIKIN_ONE_SPACE = 1370; const uint32_t DAIKIN_ZERO_SPACE = 360; const uint32_t DAIKIN_MESSAGE_SPACE = 32300; -// State Frame size +// Frame types (byte 4) and sizes +const uint8_t DAIKIN_FRAME_TYPE_STATE = 0x00; +const uint8_t DAIKIN_FRAME_TYPE_COMFORT = 0xC5; const uint8_t DAIKIN_STATE_FRAME_SIZE = 19; +const uint8_t DAIKIN_COMFORT_FRAME_SIZE = 8; class DaikinClimate final : public climate_ir::ClimateIR { public: DaikinClimate() - : climate_ir::ClimateIR(DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 1.0f, true, true, - {climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, - climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, - {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, - climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {} + : climate_ir::ClimateIR( + DAIKIN_TEMP_MIN, DAIKIN_TEMP_MAX, 0.5f, true, true, + {climate::CLIMATE_FAN_QUIET, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}, + {climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST, climate::CLIMATE_PRESET_COMFORT, + climate::CLIMATE_PRESET_SLEEP, climate::CLIMATE_PRESET_ACTIVITY, climate::CLIMATE_PRESET_NONE}) {} protected: + void setup() override; + void control(const climate::ClimateCall &call) override; // Transmit via IR the state of this climate controller. void transmit_state() override; uint8_t operation_mode_() const; uint16_t fan_speed_() const; uint8_t temperature_() const; + void set_presets_(uint8_t *remote_state) const; + /// Arms or cancels the boost timer when the preset moves into or out of boost. + void update_boost_timer_(bool was_boost); + void cancel_boost_mode_(); // Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); + + /// What the 0xC5 frame of the current transmission said about comfort, applied by the state frame + enum class PendingComfort : uint8_t { PENDING_COMFORT_UNKNOWN, PENDING_COMFORT_OFF, PENDING_COMFORT_ON }; + PendingComfort pending_comfort_{PendingComfort::PENDING_COMFORT_UNKNOWN}; + uint32_t pending_comfort_at_{0}; }; } // namespace esphome::daikin diff --git a/esphome/components/daikin_arc/__init__.py b/esphome/components/daikin_arc/__init__.py index f9164fb02b..39d99e9da0 100644 --- a/esphome/components/daikin_arc/__init__.py +++ b/esphome/components/daikin_arc/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@MagicBear"] +DOMAIN = "daikin_arc" diff --git a/esphome/components/daikin_arc/daikin_arc.cpp b/esphome/components/daikin_arc/daikin_arc.cpp index e31f72dfb9..fa01df73d8 100644 --- a/esphome/components/daikin_arc/daikin_arc.cpp +++ b/esphome/components/daikin_arc/daikin_arc.cpp @@ -7,7 +7,7 @@ namespace esphome::daikin_arc { -static const char *const TAG = "daikin.climate"; +ESPHOME_LOG_TAG(TAG, "daikin.climate"); void DaikinArcClimate::setup() { climate_ir::ClimateIR::setup(); @@ -246,10 +246,7 @@ climate::ClimateTraits DaikinArcClimate::traits() { } bool DaikinArcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGI(TAG, "checksum error"); return false; diff --git a/esphome/components/daikin_brc/__init__.py b/esphome/components/daikin_brc/__init__.py index 69002c015f..c9352db073 100644 --- a/esphome/components/daikin_brc/__init__.py +++ b/esphome/components/daikin_brc/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@hagak"] +DOMAIN = "daikin_brc" diff --git a/esphome/components/daikin_brc/climate.py b/esphome/components/daikin_brc/climate.py index c5c1d3739e..c710dc94a8 100644 --- a/esphome/components/daikin_brc/climate.py +++ b/esphome/components/daikin_brc/climate.py @@ -6,6 +6,8 @@ from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] +CONF_SUB_UNIT = "sub_unit" + daikin_brc_ns = cg.esphome_ns.namespace("daikin_brc") DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR) @@ -13,6 +15,7 @@ DaikinBrcClimate = daikin_brc_ns.class_("DaikinBrcClimate", climate_ir.ClimateIR CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).extend( { cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + cv.Optional(CONF_SUB_UNIT, default=0): cv.int_range(min=0, max=5), } ) @@ -20,3 +23,4 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(DaikinBrcClimate).ext async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) + cg.add(var.set_sub_unit(config[CONF_SUB_UNIT])) diff --git a/esphome/components/daikin_brc/daikin_brc.cpp b/esphome/components/daikin_brc/daikin_brc.cpp index 1b085013f1..c16eea733f 100644 --- a/esphome/components/daikin_brc/daikin_brc.cpp +++ b/esphome/components/daikin_brc/daikin_brc.cpp @@ -1,9 +1,10 @@ #include "daikin_brc.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::daikin_brc { -static const char *const TAG = "daikin_brc.climate"; +ESPHOME_LOG_TAG(TAG, "daikin_brc.climate"); void DaikinBrcClimate::control(const climate::ClimateCall &call) { this->mode_button_ = 0x00; @@ -19,13 +20,18 @@ void DaikinBrcClimate::transmit_state() { 0xDA, 0x17, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00}; + // The low 3 bits of the frame header byte address the indoor unit, in the preamble and the state frame + remote_state[3] |= this->sub_unit_; + remote_state[10] |= this->sub_unit_; + remote_state[6] += this->sub_unit_; // preamble checksum covers byte 3 + remote_state[12] = this->alt_mode_(); remote_state[13] = this->mode_button_; remote_state[14] = this->operation_mode_(); remote_state[17] = this->temperature_(); remote_state[18] = this->fan_speed_swing_(); - // Calculate checksum + // Calculate state frame checksum for (int i = DAIKIN_BRC_PREAMBLE_SIZE; i < DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1; i++) { remote_state[DAIKIN_BRC_TRANSMIT_FRAME_SIZE - 1] += remote_state[i]; } @@ -160,10 +166,7 @@ uint8_t DaikinBrcClimate::temperature_() { } bool DaikinBrcClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DAIKIN_BRC_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DAIKIN_BRC_STATE_FRAME_SIZE - 1); if (frame[DAIKIN_BRC_STATE_FRAME_SIZE - 1] != checksum) { ESP_LOGCONFIG(TAG, "Bad CheckSum %x", checksum); return false; @@ -256,8 +259,8 @@ bool DaikinBrcClimate::on_receive(remote_base::RemoteReceiveData data) { if (byte != 0x17) return false; } else if (pos == 3) { - // frame header - if (byte != 0x18) + // frame header, with this unit's address in the low 3 bits + if (byte != (0x18 | this->sub_unit_)) return false; } else if (pos == 4) { // frame type diff --git a/esphome/components/daikin_brc/daikin_brc.h b/esphome/components/daikin_brc/daikin_brc.h index c963f72ab6..5160e44a11 100644 --- a/esphome/components/daikin_brc/daikin_brc.h +++ b/esphome/components/daikin_brc/daikin_brc.h @@ -60,6 +60,8 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR { this->fahrenheit_ = value; this->temperature_step_ = value ? 0.5f : 1.0f; } + /// Which indoor unit (0 to 5) this climate addresses on a multi-unit system + void set_sub_unit(uint8_t value) { this->sub_unit_ = value; } protected: uint8_t mode_button_ = 0x00; @@ -75,6 +77,7 @@ class DaikinBrcClimate final : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; bool parse_state_frame_(const uint8_t frame[]); bool fahrenheit_{false}; + uint8_t sub_unit_{0}; }; } // namespace esphome::daikin_brc diff --git a/esphome/components/dallas/__init__.py b/esphome/components/dallas/__init__.py index 6c2a9d830e..7d55ae9129 100644 --- a/esphome/components/dallas/__init__.py +++ b/esphome/components/dallas/__init__.py @@ -1,5 +1,6 @@ import esphome.config_validation as cv +DOMAIN = "dallas" MULTI_CONF = True CONFIG_SCHEMA = cv.invalid( diff --git a/esphome/components/dallas_temp/__init__.py b/esphome/components/dallas_temp/__init__.py index 3f73044ca8..6a7af3ad4d 100644 --- a/esphome/components/dallas_temp/__init__.py +++ b/esphome/components/dallas_temp/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ssieb"] +DOMAIN = "dallas_temp" diff --git a/esphome/components/dallas_temp/dallas_temp.cpp b/esphome/components/dallas_temp/dallas_temp.cpp index c418362ced..45101e4981 100644 --- a/esphome/components/dallas_temp/dallas_temp.cpp +++ b/esphome/components/dallas_temp/dallas_temp.cpp @@ -3,7 +3,9 @@ namespace esphome::dallas_temp { -static const char *const TAG = "dallas.temp.sensor"; +ESPHOME_LOG_TAG(TAG, "dallas.temp.sensor"); + +static constexpr uint32_t CONVERSION_TIMEOUT_ID = 0; static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; @@ -44,7 +46,7 @@ void DallasTemperatureSensor::update() { this->send_command_(DALLAS_COMMAND_START_CONVERSION); - this->set_timeout(this->get_address_name().c_str(), this->millis_to_wait_for_conversion_(), [this] { + this->set_timeout(CONVERSION_TIMEOUT_ID, this->millis_to_wait_for_conversion_(), [this] { if (!this->read_scratch_pad_() || !this->check_scratch_pad_()) { this->publish_state(NAN); return; diff --git a/esphome/components/daly_bms/__init__.py b/esphome/components/daly_bms/__init__.py index c0d7d0aa62..01d0eef857 100644 --- a/esphome/components/daly_bms/__init__.py +++ b/esphome/components/daly_bms/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ADDRESS, CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@s1lvi0"] +DOMAIN = "daly_bms" MULTI_CONF = True DEPENDENCIES = ["uart"] diff --git a/esphome/components/daly_bms/daly_bms.cpp b/esphome/components/daly_bms/daly_bms.cpp index 45d4db4972..04ef798b69 100644 --- a/esphome/components/daly_bms/daly_bms.cpp +++ b/esphome/components/daly_bms/daly_bms.cpp @@ -6,7 +6,7 @@ namespace esphome::daly_bms { -static const char *const TAG = "daly_bms"; +ESPHOME_LOG_TAG(TAG, "daly_bms"); static const uint8_t DALY_FRAME_SIZE = 13; static const uint8_t DALY_TEMPERATURE_OFFSET = 40; @@ -129,12 +129,7 @@ void DalyBmsComponent::decode_data_(std::vector data) { while ((it = std::find(it, data.end(), 0xA5)) != data.end()) { if (data.end() - it >= DALY_FRAME_SIZE && it[1] == 0x01) { - uint8_t checksum; - int sum = 0; - for (int i = 0; i < 12; i++) { - sum += it[i]; - } - checksum = sum; + const uint8_t checksum = sum_bytes(&*it, 12); if (checksum == it[12]) { switch (it[2]) { diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index 906e9762bf..576887f6dc 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -1,13 +1,12 @@ -import base64 from pathlib import Path import re -import secrets from typing import Any from ruamel.yaml import YAML from esphome import git import esphome.codegen as cg +from esphome.components.noise import generate_encryption_key from esphome.components.packages import validate_source_shorthand import esphome.config_validation as cv from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI @@ -22,6 +21,7 @@ dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import") # is enabled DEPENDENCIES = ["api"] CODEOWNERS = ["@esphome/core"] +DOMAIN = "dashboard_import" def validate_import_url(value: Any) -> str: @@ -174,8 +174,7 @@ def import_config( "esphome": esphome_core, } if encryption: - noise_psk = secrets.token_bytes(32) - key = base64.b64encode(noise_psk).decode() + key = generate_encryption_key() config["api"] = {"encryption": {"key": key}} output = dump(config) diff --git a/esphome/components/datetime/__init__.py b/esphome/components/datetime/__init__.py index 3a33e5e004..20fd258cb0 100644 --- a/esphome/components/datetime/__init__.py +++ b/esphome/components/datetime/__init__.py @@ -33,6 +33,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType, SafeExpType CODEOWNERS = ["@rfdarter", "@jesserockz"] +DOMAIN = "datetime" IS_PLATFORM_COMPONENT = True @@ -42,9 +43,6 @@ DateEntity = datetime_ns.class_("DateEntity", DateTimeBase) TimeEntity = datetime_ns.class_("TimeEntity", DateTimeBase) DateTimeEntity = datetime_ns.class_("DateTimeEntity", DateTimeBase) -DateTimeStateTrigger = datetime_ns.class_( - "DateTimeStateTrigger", automation.Trigger.template(cg.ESPTime) -) OnTimeTrigger = datetime_ns.class_( "OnTimeTrigger", automation.Trigger, cg.Component, cg.Parented.template(TimeEntity) @@ -74,11 +72,7 @@ def _validate_time_present(config: ConfigType) -> ConfigType: _DATETIME_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend( cv.Schema( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DateTimeStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), } ) @@ -143,9 +137,16 @@ async def setup_datetime_core_(var: MockObj, config: ConfigType) -> None: await mqtt.register_mqtt_component(mqtt_, config) if web_server_config := config.get(CONF_WEB_SERVER): await web_server.add_entity_config(var, web_server_config) + # The state callback carries nothing; the automation gets the value as an ESPTime. for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.ESPTime, "x")], conf) + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.ESPTime, "x")], + conf, + params=[], + forward=[automation.parent_ref(var).state_as_esptime()], + ) if CONF_TIME_ID in config: rtc = await cg.get_variable(config[CONF_TIME_ID]) diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index b99b89259f..795d54afd9 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -7,7 +7,7 @@ namespace esphome::datetime { -static const char *const TAG = "datetime.date_entity"; +ESPHOME_LOG_TAG(TAG, "datetime.date_entity"); void DateEntity::publish_state() { if (this->year_ == 0 || this->month_ == 0 || this->day_ == 0) { diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index f99debb692..fdbb193006 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -31,14 +31,4 @@ class DateTimeBase : public EntityBase { #endif }; -class DateTimeStateTrigger final : public Trigger { - public: - explicit DateTimeStateTrigger(DateTimeBase *parent) : parent_(parent) { - parent->add_on_state_callback([this]() { this->trigger(this->parent_->state_as_esptime()); }); - } - - protected: - DateTimeBase *parent_; -}; - } // namespace esphome::datetime diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index 8f180fd081..6aa1416343 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -7,7 +7,7 @@ namespace esphome::datetime { -static const char *const TAG = "datetime.datetime_entity"; +ESPHOME_LOG_TAG(TAG, "datetime.datetime_entity"); void DateTimeEntity::publish_state() { if (this->year_ == 0 || this->month_ == 0 || this->day_ == 0) { diff --git a/esphome/components/datetime/time_entity.cpp b/esphome/components/datetime/time_entity.cpp index da4c9eb31e..ea1a97256c 100644 --- a/esphome/components/datetime/time_entity.cpp +++ b/esphome/components/datetime/time_entity.cpp @@ -7,7 +7,7 @@ namespace esphome::datetime { -static const char *const TAG = "datetime.time_entity"; +ESPHOME_LOG_TAG(TAG, "datetime.time_entity"); void TimeEntity::publish_state() { if (this->hour_ > 23) { diff --git a/esphome/components/debug/__init__.py b/esphome/components/debug/__init__.py index a889d13329..889cfaaa68 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 @@ -16,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger"] +DOMAIN = "debug" CONF_DEBUG_ID = "debug_id" debug_ns = cg.esphome_ns.namespace("debug") @@ -51,13 +53,15 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("HWINFO", True) # gdb thread support zephyr_add_prj_conf("DEBUG_THREAD_INFO", True) - # RTT + # RTT sources live in the segger project + include_west_project("segger") zephyr_add_prj_conf("USE_SEGGER_RTT", True) zephyr_add_prj_conf("RTT_CONSOLE", True) zephyr_add_prj_conf("LOG", True) zephyr_add_prj_conf("LOG_BLOCK_IN_THREAD", True) zephyr_add_prj_conf("LOG_BUFFER_SIZE", 4096) zephyr_add_prj_conf("SEGGER_RTT_MODE_BLOCK_IF_FIFO_FULL", True) + zephyr_add_prj_conf("PRINTK", True) var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) cg.add_define("USE_DEBUG") diff --git a/esphome/components/debug/debug_component.cpp b/esphome/components/debug/debug_component.cpp index 97f4522c62..97f8550c5b 100644 --- a/esphome/components/debug/debug_component.cpp +++ b/esphome/components/debug/debug_component.cpp @@ -11,7 +11,7 @@ namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); void DebugComponent::dump_config() { ESP_LOGCONFIG(TAG, "Debug component:"); @@ -58,7 +58,7 @@ void DebugComponent::loop() { if (new_free_heap < this->free_heap_ / 2) { this->free_heap_ = new_free_heap; ESP_LOGD(TAG, "Free Heap Size: %" PRIu32 " bytes", this->free_heap_); - this->status_momentary_warning("heap", 1000); + this->status_momentary_warning(1000); } #ifdef USE_SENSOR diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 8e1a67224e..1ffc0a2eb6 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -19,7 +19,7 @@ namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); // index by values returned by esp_reset_reason diff --git a/esphome/components/debug/debug_esp8266.cpp b/esphome/components/debug/debug_esp8266.cpp index acce28818c..5b62eb6190 100644 --- a/esphome/components/debug/debug_esp8266.cpp +++ b/esphome/components/debug/debug_esp8266.cpp @@ -17,7 +17,7 @@ extern const char *core_release; namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); // PROGMEM string table for reset reasons, indexed by reason code (0-6), with "Unknown" as fallback // clang-format off diff --git a/esphome/components/debug/debug_libretiny.cpp b/esphome/components/debug/debug_libretiny.cpp index 55b29310a1..a1166758e2 100644 --- a/esphome/components/debug/debug_libretiny.cpp +++ b/esphome/components/debug/debug_libretiny.cpp @@ -4,7 +4,7 @@ namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); const char *DebugComponent::get_reset_reason_(std::span buffer) { // Return the static string directly diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index 4ace4be0a3..7cedac287c 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -15,7 +15,7 @@ #endif namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); const char *DebugComponent::get_reset_reason_(std::span buffer) { char *buf = buffer.data(); diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index e23a0f668a..f3a5f3d770 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -1,6 +1,8 @@ #include "debug_component.h" #ifdef USE_ZEPHYR +#include #include +#include "esphome/core/alloc_helpers.h" #include "esphome/core/log.h" #include #include @@ -12,7 +14,7 @@ namespace esphome::debug { -static const char *const TAG = "debug"; +ESPHOME_LOG_TAG(TAG, "debug"); constexpr std::uintptr_t MBR_PARAM_PAGE_ADDR = 0xFFC; constexpr std::uintptr_t MBR_BOOTLOADER_ADDR = 0xFF8; @@ -399,18 +401,17 @@ size_t DebugComponent::get_device_info_(std::span #endif } #endif - auto uicr = [](volatile uint32_t *data, uint8_t size) { - std::string res; + // 13 words of 8 hex digits, separated by spaces + char uicr_buf[13 * 9]; + auto uicr = [&uicr_buf](volatile uint32_t *data, uint8_t size) { + size_t len = 0; for (size_t i = 0; i < size; i++) { - if (i > 0) { - res += ' '; - } - res += format_hex_pretty(data[i], '\0', false); + len = buf_append_printf(uicr_buf, sizeof(uicr_buf), len, i > 0 ? " %08" PRIX32 : "%08" PRIX32, data[i]); } - return res; + return uicr_buf; }; - ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13).c_str()); - ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12).c_str()); + ESP_LOGD(TAG, " NRFFW %s", uicr(NRF_UICR->NRFFW, 13)); + ESP_LOGD(TAG, " NRFHW %s", uicr(NRF_UICR->NRFHW, 12)); #ifdef ESPHOME_LOG_HAS_VERBOSE log_peripherals_info(); #endif diff --git a/esphome/components/debug/sensor.py b/esphome/components/debug/sensor.py index 80d1daa81f..07c67a2b93 100644 --- a/esphome/components/debug/sensor.py +++ b/esphome/components/debug/sensor.py @@ -106,32 +106,13 @@ CONFIG_SCHEMA = { async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - if free_conf := config.get(CONF_FREE): - sens = await sensor.new_sensor(free_conf) - cg.add(debug_component.set_free_sensor(sens)) - - if block_conf := config.get(CONF_BLOCK): - sens = await sensor.new_sensor(block_conf) - cg.add(debug_component.set_block_sensor(sens)) - - if fragmentation_conf := config.get(CONF_FRAGMENTATION): - sens = await sensor.new_sensor(fragmentation_conf) - cg.add(debug_component.set_fragmentation_sensor(sens)) - - if min_free_conf := config.get(CONF_MIN_FREE): - sens = await sensor.new_sensor(min_free_conf) - cg.add(debug_component.set_min_free_sensor(sens)) - - if loop_time_conf := config.get(CONF_LOOP_TIME): - sens = await sensor.new_sensor(loop_time_conf) - cg.add(debug_component.set_loop_time_sensor(sens)) - - if psram_conf := config.get(CONF_PSRAM): - sens = await sensor.new_sensor(psram_conf) - cg.add(debug_component.set_psram_sensor(sens)) - - if cpu_freq_conf := config.get(CONF_CPU_FREQUENCY): - sens = await sensor.new_sensor(cpu_freq_conf) - cg.add(debug_component.set_cpu_frequency_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_FREE, hub.set_free_sensor) + await sensors(CONF_BLOCK, hub.set_block_sensor) + await sensors(CONF_FRAGMENTATION, hub.set_fragmentation_sensor) + await sensors(CONF_MIN_FREE, hub.set_min_free_sensor) + await sensors(CONF_LOOP_TIME, hub.set_loop_time_sensor) + await sensors(CONF_PSRAM, hub.set_psram_sensor) + await sensors(CONF_CPU_FREQUENCY, hub.set_cpu_frequency_sensor) diff --git a/esphome/components/debug/text_sensor.py b/esphome/components/debug/text_sensor.py index 2e02af67cb..43ed0d15c6 100644 --- a/esphome/components/debug/text_sensor.py +++ b/esphome/components/debug/text_sensor.py @@ -31,11 +31,8 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config: ConfigType) -> None: - debug_component = await cg.get_variable(config[CONF_DEBUG_ID]) + hub = await cg.get_variable(config[CONF_DEBUG_ID]) - if CONF_DEVICE in config: - sens = await text_sensor.new_text_sensor(config[CONF_DEVICE]) - cg.add(debug_component.set_device_info_sensor(sens)) - if CONF_RESET_REASON in config: - sens = await text_sensor.new_text_sensor(config[CONF_RESET_REASON]) - cg.add(debug_component.set_reset_reason_sensor(sens)) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_DEVICE, hub.set_device_info_sensor) + await text_sensors(CONF_RESET_REASON, hub.set_reset_reason_sensor) diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index dc03708645..2b820c9be6 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,29 +480,18 @@ 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), + ) +DOMAIN = "deep_sleep" FILTER_SOURCE_FILES = filter_source_files_from_platform( { "deep_sleep_esp32.cpp": { diff --git a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp index a955095875..47ebd3641e 100644 --- a/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp +++ b/esphome/components/deep_sleep/deep_sleep_bk72xx.cpp @@ -5,7 +5,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { diff --git a/esphome/components/deep_sleep/deep_sleep_component.cpp b/esphome/components/deep_sleep/deep_sleep_component.cpp index d33102bf4f..f363a0f0ac 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.cpp +++ b/esphome/components/deep_sleep/deep_sleep_component.cpp @@ -4,7 +4,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); // 5 seconds for deep sleep to ensure clean disconnect from Home Assistant static const uint32_t TEARDOWN_TIMEOUT_DEEP_SLEEP_MS = 5000; diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 0bbca4c5c4..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 @@ -298,15 +296,4 @@ template class EnterDeepSleepAction final : public Action #endif }; -template -class PreventDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->prevent_deep_sleep(); } -}; - -template class AllowDeepSleepAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->allow_deep_sleep(); } -}; - } // namespace esphome::deep_sleep diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index 20297028b2..7cf90bc33e 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -28,7 +28,7 @@ namespace esphome::deep_sleep { // - Touch: Touch pad wakeup (esp_sleep_enable_touchpad_wakeup) // - GPIO wakeup: GPIO wakeup for RTC pins -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { @@ -111,7 +111,17 @@ void DeepSleepComponent::deep_sleep_() { if (this->sleep_duration_.has_value()) esp_sleep_enable_timer_wakeup(*this->sleep_duration_); - // Single pin wakeup (ext0) - ESP32, S2, S3 only +#ifndef SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP +#ifdef USE_GPIO_HOLD + // Variants without per-pin deep sleep hold need the global enable + gpio_deep_sleep_hold_en(); +#else + if (this->wakeup_pin_ != nullptr) + gpio_deep_sleep_hold_en(); +#endif +#endif + + // Single pin wakeup (ext0) - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ !defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) @@ -124,11 +134,6 @@ void DeepSleepComponent::deep_sleep_() { } gpio_sleep_set_direction(gpio_pin, GPIO_MODE_INPUT); gpio_hold_en(gpio_pin); -#if !SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP - // Some ESP32 variants support holding a single GPIO during deep sleep without this function - // For those variants, gpio_hold_en() is sufficient to hold the pin state during deep sleep - gpio_deep_sleep_hold_en(); -#endif bool level = !this->wakeup_pin_->is_inverted(); if (this->wakeup_pin_mode_ == WAKEUP_PIN_MODE_INVERT_WAKEUP && this->wakeup_pin_->digital_read()) { level = !level; diff --git a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp index 2b98f4b855..408d5a6fe6 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp8266.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp8266.cpp @@ -11,7 +11,7 @@ extern "C" { namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); #ifdef USE_DEEP_SLEEP_ON_WAKE WakeupCause get_wakeup_cause() { diff --git a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp index cadf7bf42d..aff5c0e9c7 100644 --- a/esphome/components/deep_sleep/deep_sleep_zephyr.cpp +++ b/esphome/components/deep_sleep/deep_sleep_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::deep_sleep { -static const char *const TAG = "deep_sleep"; +ESPHOME_LOG_TAG(TAG, "deep_sleep"); // The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and // WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a diff --git a/esphome/components/delonghi/__init__.py b/esphome/components/delonghi/__init__.py index 0a81eb2da7..ba66077c84 100644 --- a/esphome/components/delonghi/__init__.py +++ b/esphome/components/delonghi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@grob6000"] +DOMAIN = "delonghi" diff --git a/esphome/components/delonghi/delonghi.cpp b/esphome/components/delonghi/delonghi.cpp index fab2d68347..3ed794daea 100644 --- a/esphome/components/delonghi/delonghi.cpp +++ b/esphome/components/delonghi/delonghi.cpp @@ -1,9 +1,10 @@ #include "delonghi.h" #include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/helpers.h" namespace esphome::delonghi { -static const char *const TAG = "delonghi.climate"; +ESPHOME_LOG_TAG(TAG, "delonghi.climate"); void DelonghiClimate::transmit_state() { uint8_t remote_state[DELONGHI_STATE_FRAME_SIZE] = {0}; @@ -102,10 +103,7 @@ uint8_t DelonghiClimate::temperature_() { } bool DelonghiClimate::parse_state_frame_(const uint8_t frame[]) { - uint8_t checksum = 0; - for (int i = 0; i < (DELONGHI_STATE_FRAME_SIZE - 1); i++) { - checksum += frame[i]; - } + const uint8_t checksum = sum_bytes(frame, DELONGHI_STATE_FRAME_SIZE - 1); if (frame[DELONGHI_STATE_FRAME_SIZE - 1] != checksum) { return false; } diff --git a/esphome/components/demo/__init__.py b/esphome/components/demo/__init__.py index 75feaa65af..8f2d5ded20 100644 --- a/esphome/components/demo/__init__.py +++ b/esphome/components/demo/__init__.py @@ -76,6 +76,7 @@ AUTO_LOAD = [ "text_sensor", "valve", ] +DOMAIN = "demo" demo_ns = cg.esphome_ns.namespace("demo") DemoAlarmControlPanel = demo_ns.class_( diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index e85d2a17ba..79cfc8f8ca 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -33,7 +33,7 @@ class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { case ACP_STATE_ARMED_AWAY: if (this->get_requires_code_to_arm()) { if (!code.has_value() || *code != "1234") { - this->status_momentary_error("invalid_code", 5000); + this->status_momentary_error(5000); return; } } @@ -42,7 +42,7 @@ class DemoAlarmControlPanel final : public AlarmControlPanel, public Component { case ACP_STATE_DISARMED: if (this->get_requires_code()) { if (!code.has_value() || *code != "1234") { - this->status_momentary_error("invalid_code", 5000); + this->status_momentary_error(5000); return; } } diff --git a/esphome/components/demo/demo_cover.h b/esphome/components/demo/demo_cover.h index aa12c885f4..eeb188a463 100644 --- a/esphome/components/demo/demo_cover.h +++ b/esphome/components/demo/demo_cover.h @@ -36,6 +36,8 @@ class DemoCover final : public cover::Cover, public Component { } protected: + static constexpr uint32_t MOVE_TIMEOUT_ID = 0; + void control(const cover::CoverCall &call) override { auto pos = call.get_position(); if (pos.has_value()) { @@ -43,7 +45,7 @@ class DemoCover final : public cover::Cover, public Component { this->current_operation = target > this->position ? cover::COVER_OPERATION_OPENING : cover::COVER_OPERATION_CLOSING; - this->set_timeout("move", 2000, [this, target]() { + this->set_timeout(MOVE_TIMEOUT_ID, 2000, [this, target]() { this->current_operation = cover::COVER_OPERATION_IDLE; this->position = target; this->publish_state(); @@ -54,7 +56,7 @@ class DemoCover final : public cover::Cover, public Component { this->tilt = *tilt; } if (call.get_stop()) { - this->cancel_timeout("move"); + this->cancel_timeout(MOVE_TIMEOUT_ID); } this->publish_state(); diff --git a/esphome/components/dew_point/__init__.py b/esphome/components/dew_point/__init__.py index 3b852436c3..ab61f55f0b 100644 --- a/esphome/components/dew_point/__init__.py +++ b/esphome/components/dew_point/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@CFlix"] +DOMAIN = "dew_point" diff --git a/esphome/components/dew_point/dew_point.cpp b/esphome/components/dew_point/dew_point.cpp index 04ac305e3d..7012bcbb75 100644 --- a/esphome/components/dew_point/dew_point.cpp +++ b/esphome/components/dew_point/dew_point.cpp @@ -3,7 +3,7 @@ namespace esphome::dew_point { -static const char *const TAG = "dew_point.sensor"; +ESPHOME_LOG_TAG(TAG, "dew_point.sensor"); void DewPointComponent::setup() { // Register callbacks for sensor updates diff --git a/esphome/components/dfplayer/__init__.py b/esphome/components/dfplayer/__init__.py index d2120deaa6..d4ec9d6590 100644 --- a/esphome/components/dfplayer/__init__.py +++ b/esphome/components/dfplayer/__init__.py @@ -9,12 +9,10 @@ from esphome.const import CONF_DEVICE, CONF_FILE, CONF_ID, CONF_VOLUME DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] +DOMAIN = "dfplayer" 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" @@ -199,16 +197,12 @@ automation.register_apply_action( ) -@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 1195f595c8..c45e895b38 100644 --- a/esphome/components/dfplayer/dfplayer.cpp +++ b/esphome/components/dfplayer/dfplayer.cpp @@ -4,7 +4,7 @@ namespace esphome::dfplayer { -static const char *const TAG = "dfplayer"; +ESPHOME_LOG_TAG(TAG, "dfplayer"); void DFPlayer::next() { this->ack_set_is_playing_ = true; diff --git a/esphome/components/dfplayer/dfplayer.h b/esphome/components/dfplayer/dfplayer.h index ad91dcfc94..957583e4ff 100644 --- a/esphome/components/dfplayer/dfplayer.h +++ b/esphome/components/dfplayer/dfplayer.h @@ -77,9 +77,4 @@ class DFPlayer final : public uart::UARTDevice, public Component { CallbackManager on_finished_playback_callback_; }; -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..d4e7aed91d 100644 --- a/esphome/components/dfrobot_sen0395/__init__.py +++ b/esphome/components/dfrobot_sen0395/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@niklasweber"] DEPENDENCIES = ["uart"] +DOMAIN = "dfrobot_sen0395" MULTI_CONF = True dfrobot_sen0395_ns = cg.esphome_ns.namespace("dfrobot_sen0395") @@ -20,9 +21,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 +47,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 +188,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/commands.cpp b/esphome/components/dfrobot_sen0395/commands.cpp index 570bfef943..f5aee54943 100644 --- a/esphome/components/dfrobot_sen0395/commands.cpp +++ b/esphome/components/dfrobot_sen0395/commands.cpp @@ -8,7 +8,7 @@ namespace esphome::dfrobot_sen0395 { -static const char *const TAG = "dfrobot_sen0395.commands"; +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395.commands"); uint8_t Command::execute(DfrobotSen0395Component *parent) { this->parent_ = parent; diff --git a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp index 42cfd5dff6..9c092982e5 100644 --- a/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp +++ b/esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp @@ -5,7 +5,7 @@ namespace esphome::dfrobot_sen0395 { -static const char *const TAG = "dfrobot_sen0395"; +ESPHOME_LOG_TAG(TAG, "dfrobot_sen0395"); const char ASCII_CR = 0x0D; const char ASCII_LF = 0x0A; 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/__init__.py b/esphome/components/dht/__init__.py index 71a87b6ae5..9def421103 100644 --- a/esphome/components/dht/__init__.py +++ b/esphome/components/dht/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "dht" diff --git a/esphome/components/dht/dht.cpp b/esphome/components/dht/dht.cpp index 2196f3a982..12b8b0feba 100644 --- a/esphome/components/dht/dht.cpp +++ b/esphome/components/dht/dht.cpp @@ -4,7 +4,7 @@ namespace esphome::dht { -static const char *const TAG = "dht"; +ESPHOME_LOG_TAG(TAG, "dht"); void DHT::setup() { this->t_pin_->digital_write(true); 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/__init__.py b/esphome/components/dht12/__init__.py index e69de29bb2..0078da3300 100644 --- a/esphome/components/dht12/__init__.py +++ b/esphome/components/dht12/__init__.py @@ -0,0 +1 @@ +DOMAIN = "dht12" diff --git a/esphome/components/dht12/dht12.cpp b/esphome/components/dht12/dht12.cpp index 78f9140929..e099b65d5b 100644 --- a/esphome/components/dht12/dht12.cpp +++ b/esphome/components/dht12/dht12.cpp @@ -7,7 +7,7 @@ namespace esphome::dht12 { -static const char *const TAG = "dht12"; +ESPHOME_LOG_TAG(TAG, "dht12"); void DHT12Component::update() { uint8_t data[5]; 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 b664579be9..0b871af30c 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -21,7 +21,6 @@ from esphome.const import ( CONF_PAGES, CONF_ROTATION, CONF_TO, - CONF_TRIGGER_ID, CONF_UPDATE_INTERVAL, CONF_WIDTH, SCHEDULER_DONT_RUN, @@ -39,17 +38,13 @@ DisplayPage = display_ns.class_("DisplayPage") DisplayPagePtr = DisplayPage.operator("ptr") DisplayRef = Display.operator("ref") DisplayPageShowAction = display_ns.class_("DisplayPageShowAction", automation.Action) -DisplayIsDisplayingPageCondition = display_ns.class_( - "DisplayIsDisplayingPageCondition", automation.Condition -) -DisplayOnPageChangeTrigger = display_ns.class_( - "DisplayOnPageChangeTrigger", automation.Trigger -) CONF_ON_PAGE_CHANGE = "on_page_change" CONF_SHOW_TEST_CARD = "show_test_card" CONF_UNSPECIFIED = "unspecified" +_request_page_change_slot = cg.slot_counter("DISPLAY_PAGE_CHANGE_CALLBACK_COUNT") + DISPLAY_ROTATIONS = { 0: display_ns.DISPLAY_ROTATION_0_DEGREES, 90: display_ns.DISPLAY_ROTATION_90_DEGREES, @@ -102,9 +97,6 @@ FULL_DISPLAY_SCHEMA = BASIC_DISPLAY_SCHEMA.extend( ), cv.Optional(CONF_ON_PAGE_CHANGE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayOnPageChangeTrigger - ), cv.Optional(CONF_FROM): cv.use_id(DisplayPage), cv.Optional(CONF_TO): cv.use_id(DisplayPage), } @@ -140,15 +132,18 @@ async def setup_display_core_(var, config): pages.append(page) cg.add(var.set_pages(pages)) for conf in config.get(CONF_ON_PAGE_CHANGE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - if CONF_FROM in conf: - page = await cg.get_variable(conf[CONF_FROM]) - cg.add(trigger.set_from(page)) - if CONF_TO in conf: - page = await cg.get_variable(conf[CONF_TO]) - cg.add(trigger.set_to(page)) - await automation.build_automation( - trigger, [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], conf + _request_page_change_slot(str(var)) + checks = [] + for key, name in ((CONF_FROM, "from"), (CONF_TO, "to")): + if (page_id := conf.get(key)) is not None: + page = await cg.get_variable(page_id) + checks.append(f"{name} == ::{page}") + await automation.build_callback_automation( + var, + "add_on_page_change_callback", + [(DisplayPagePtr, "from"), (DisplayPagePtr, "to")], + conf, + when=" && ".join(checks) or None, ) if config.get(CONF_SHOW_TEST_CARD): cg.add(var.show_test_card()) @@ -301,9 +296,8 @@ automation.register_apply_action( ) -@automation.register_condition( +automation.register_apply_condition( "display.is_displaying_page", - DisplayIsDisplayingPageCondition, cv.maybe_simple_value( { cv.GenerateID(CONF_ID): cv.use_id(Display), @@ -311,13 +305,8 @@ automation.register_apply_action( }, 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 66fadf12ec..0bd8346953 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -8,7 +8,7 @@ #include "esphome/core/log.h" namespace esphome::display { -static const char *const TAG = "display"; +ESPHOME_LOG_TAG(TAG, "display"); // COLOR_OFF and COLOR_ON are now inline constexpr in display.h @@ -678,12 +678,14 @@ void Display::set_pages(std::vector pages) { } void Display::show_page(DisplayPage *page) { - this->previous_page_ = this->page_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + DisplayPage *previous = this->page_; this->page_ = page; - if (this->previous_page_ != this->page_) { - for (auto *t : on_page_change_triggers_) - t->process(this->previous_page_, this->page_); - } + if (previous != page) + this->page_change_callback_.call(previous, page); +#else + this->page_ = page; +#endif } void Display::do_update_() { @@ -700,11 +702,6 @@ void Display::do_update_() { this->clear_clipping_(); } -void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) { - if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to)) - this->trigger(from, to); -} - void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ESPTime time) { char buffer[64]; diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index af5a7bc132..7d1053f4fa 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -7,6 +7,8 @@ #include "esphome/core/color.h" #include "esphome/core/automation.h" +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/time.h" #include "esphome/core/log.h" #include "display_color_utils.h" @@ -173,7 +175,6 @@ enum RegularPolygonDrawing { class Display; class DisplayPage; -class DisplayOnPageChangeTrigger; /** Optimized display writer that uses function pointers for stateless lambdas. * @@ -287,13 +288,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->get_rotation(), prefix, log_display_obj->get_width(), \ + log_display_obj->get_height()); \ } /// Turn the pixel OFF. @@ -700,7 +703,11 @@ class Display : public PollingComponent { const DisplayPage *get_active_page() const { return this->page_; } - void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); } +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + template void add_on_page_change_callback(F &&callback) { + this->page_change_callback_.add(std::forward(callback)); + } +#endif /// Internal method to set the display rotation with. virtual void set_rotation(DisplayRotation rotation); @@ -708,7 +715,7 @@ class Display : public PollingComponent { // Internal method to set display auto clearing. void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } - DisplayRotation get_rotation() const { return this->rotation_; } + virtual DisplayRotation get_rotation() const { return this->rotation_; } /** Get the type of display that the buffer corresponds to. In case of dynamically configurable displays, * returns the type the display is currently configured to. @@ -807,8 +814,9 @@ class Display : public PollingComponent { DisplayRotation rotation_{DISPLAY_ROTATION_0_DEGREES}; display_writer_t writer_{}; DisplayPage *page_{nullptr}; - DisplayPage *previous_page_{nullptr}; - std::vector on_page_change_triggers_; +#ifdef DISPLAY_PAGE_CHANGE_CALLBACK_COUNT + StaticCallbackManager page_change_callback_; +#endif bool auto_clear_enabled_{true}; std::vector clipping_rectangle_; uint8_t wdt_pixel_counter_{0}; @@ -848,30 +856,6 @@ template class DisplayPageShowAction final : public Action 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); } - void process(DisplayPage *from, DisplayPage *to); - void set_from(DisplayPage *p) { this->from_ = p; } - void set_to(DisplayPage *p) { this->to_ = p; } - - protected: - DisplayPage *from_{nullptr}; - DisplayPage *to_{nullptr}; -}; - const LogString *text_align_to_string(TextAlign textalign); } // namespace esphome::display diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index d564ea67bd..4558092702 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -6,7 +6,7 @@ namespace esphome::display { -static const char *const TAG = "display"; +ESPHOME_LOG_TAG(TAG, "display"); void DisplayBuffer::init_internal_(uint32_t buffer_length) { RAMAllocator allocator; diff --git a/esphome/components/display/rect.cpp b/esphome/components/display/rect.cpp index 3ecf6d1cf1..8eb9288a5a 100644 --- a/esphome/components/display/rect.cpp +++ b/esphome/components/display/rect.cpp @@ -4,7 +4,7 @@ namespace esphome::display { -static const char *const TAG = "display"; +ESPHOME_LOG_TAG(TAG, "display"); void Rect::expand(int16_t horizontal, int16_t vertical) { if (this->is_set() && (this->w >= (-2 * horizontal)) && (this->h >= (-2 * vertical))) { diff --git a/esphome/components/display_menu_base/__init__.py b/esphome/components/display_menu_base/__init__.py index 2120abe5f7..791242cda1 100644 --- a/esphome/components/display_menu_base/__init__.py +++ b/esphome/components/display_menu_base/__init__.py @@ -20,11 +20,13 @@ from esphome.const import ( CONF_ON_VALUE, CONF_SWITCH, CONF_TEXT, - CONF_TRIGGER_ID, CONF_TYPE, ) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType CODEOWNERS = ["@numo68"] +DOMAIN = "display_menu_base" display_menu_base_ns = cg.esphome_ns.namespace("display_menu_base") @@ -55,18 +57,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") @@ -97,26 +87,6 @@ MENU_MODES = { CONF_JOYSTICK: MenuMode.MENU_MODE_JOYSTICK, } -DisplayMenuOnEnterTrigger = display_menu_base_ns.class_( - "DisplayMenuOnEnterTrigger", automation.Trigger -) - -DisplayMenuOnLeaveTrigger = display_menu_base_ns.class_( - "DisplayMenuOnLeaveTrigger", automation.Trigger -) - -DisplayMenuOnValueTrigger = display_menu_base_ns.class_( - "DisplayMenuOnValueTrigger", automation.Trigger -) - -DisplayMenuOnNextTrigger = display_menu_base_ns.class_( - "DisplayMenuOnNextTrigger", automation.Trigger -) - -DisplayMenuOnPrevTrigger = display_menu_base_ns.class_( - "DisplayMenuOnPrevTrigger", automation.Trigger -) - def validate_format(format): if re.search(r"^%[+-]*(\d+)?(\.\d+)?[fg]$", format) is None: @@ -140,44 +110,20 @@ MENU_ITEM_COMMON_SCHEMA = cv.Schema( MENU_ITEM_ENTER_LEAVE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend( { - cv.Optional(CONF_ON_ENTER): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnEnterTrigger - ), - } - ), - cv.Optional(CONF_ON_LEAVE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnLeaveTrigger - ), - } - ), + cv.Optional(CONF_ON_ENTER): automation.validate_automation({}), + cv.Optional(CONF_ON_LEAVE): automation.validate_automation({}), } ) MENU_ITEM_VALUE_SCHEMA = MENU_ITEM_COMMON_SCHEMA.extend( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnValueTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), } ) MENU_ITEM_ENTER_LEAVE_VALUE_SCHEMA = MENU_ITEM_ENTER_LEAVE_SCHEMA.extend( { - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnValueTrigger - ), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation({}), } ) @@ -241,20 +187,8 @@ MENU_ITEM_SCHEMA = cv.typed_schema( cv.GenerateID(CONF_ID): cv.declare_id(MenuItemCustom), cv.Optional(CONF_IMMEDIATE_EDIT, default=False): cv.boolean, cv.Optional(CONF_VALUE_LAMBDA): cv.returning_lambda, - cv.Optional(CONF_ON_NEXT): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnNextTrigger - ), - } - ), - cv.Optional(CONF_ON_PREV): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnPrevTrigger - ), - } - ), + cv.Optional(CONF_ON_NEXT): automation.validate_automation({}), + cv.Optional(CONF_ON_PREV): automation.validate_automation({}), } ), }, @@ -267,20 +201,8 @@ DISPLAY_MENU_BASE_SCHEMA = cv.Schema( cv.Optional(CONF_ACTIVE, default=True): cv.boolean, cv.GenerateID(CONF_ROOT_ITEM_ID): cv.declare_id(MenuItemMenu), cv.Optional(CONF_MODE, default=CONF_ROTARY): cv.enum(MENU_MODES), - cv.Optional(CONF_ON_ENTER): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnEnterTrigger - ), - } - ), - cv.Optional(CONF_ON_LEAVE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DisplayMenuOnLeaveTrigger - ), - } - ), + cv.Optional(CONF_ON_ENTER): automation.validate_automation({}), + cv.Optional(CONF_ON_LEAVE): automation.validate_automation({}), cv.Required(CONF_ITEMS): cv.All( cv.ensure_list(MENU_ITEM_SCHEMA), cv.Length(min=1) ), @@ -294,85 +216,41 @@ 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 +# Each item callback carries nothing; the automation receives the item itself. +_ITEM_CALLBACKS = ( + (CONF_ON_ENTER, "add_on_enter_callback"), + (CONF_ON_LEAVE, "add_on_leave_callback"), + (CONF_ON_VALUE, "add_on_value_callback"), + (CONF_ON_NEXT, "add_on_next_callback"), + (CONF_ON_PREV, "add_on_prev_callback"), ) -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 _build_item_automations(item: MockObj, config: ConfigType) -> None: + for conf_key, callback_method in _ITEM_CALLBACKS: + for conf in config.get(conf_key, []): + await automation.build_parent_callback_automation( + item, callback_method, (MenuItemConstPtr, "it"), conf + ) async def menu_item_to_code(menu, config, parent): @@ -413,21 +291,7 @@ async def menu_item_to_code(menu, config, parent): cg.add(item.set_switch_variable(var)) cg.add(item.set_on_text(config[CONF_ON_TEXT])) cg.add(item.set_off_text(config[CONF_OFF_TEXT])) - for conf in config.get(CONF_ON_ENTER, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_LEAVE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_NEXT, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_PREV, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + await _build_item_automations(item, config) async def display_menu_to_code(menu, config): @@ -437,9 +301,4 @@ async def display_menu_to_code(menu, config): cg.add(menu.set_mode(config[CONF_MODE])) for c in config[CONF_ITEMS]: await menu_item_to_code(menu, c, root_item) - for conf in config.get(CONF_ON_ENTER, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) - for conf in config.get(CONF_ON_LEAVE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], root_item) - await automation.build_automation(trigger, [(MenuItemConstPtr, "it")], conf) + await _build_item_automations(root_item, config) diff --git a/esphome/components/display_menu_base/automation.h b/esphome/components/display_menu_base/automation.h deleted file mode 100644 index be0044ffa4..0000000000 --- a/esphome/components/display_menu_base/automation.h +++ /dev/null @@ -1,146 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "display_menu_base.h" - -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) { - parent->add_on_enter_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnLeaveTrigger final : public Trigger { - public: - explicit DisplayMenuOnLeaveTrigger(MenuItem *parent) : parent_(parent) { - parent->add_on_leave_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnValueTrigger final : public Trigger { - public: - explicit DisplayMenuOnValueTrigger(MenuItem *parent) : parent_(parent) { - parent->add_on_value_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItem *parent_; -}; - -class DisplayMenuOnNextTrigger final : public Trigger { - public: - explicit DisplayMenuOnNextTrigger(MenuItemCustom *parent) : parent_(parent) { - parent->add_on_next_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItemCustom *parent_; -}; - -class DisplayMenuOnPrevTrigger final : public Trigger { - public: - explicit DisplayMenuOnPrevTrigger(MenuItemCustom *parent) : parent_(parent) { - parent->add_on_prev_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - MenuItemCustom *parent_; -}; - -} // namespace esphome::display_menu_base diff --git a/esphome/components/dlms_meter/__init__.py b/esphome/components/dlms_meter/__init__.py index 00a1694cc3..eed702b6f1 100644 --- a/esphome/components/dlms_meter/__init__.py +++ b/esphome/components/dlms_meter/__init__.py @@ -1,4 +1,3 @@ -import logging import re from typing import Any @@ -13,12 +12,12 @@ from esphome.const import ( CONF_RECEIVE_TIMEOUT, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@SimonFischer04", "@Tomer27cz", "@latonita", "@PolarGoose"] DEPENDENCIES = ["uart"] +DOMAIN = "dlms_meter" CONF_DLMS_METER_ID = "dlms_meter_id" CONF_DECRYPTION_KEY = "decryption_key" @@ -29,19 +28,17 @@ CONF_SKIP_CRC = "skip_crc" CONF_DEFAULT_OBIS = "default_obis" CONF_PROVIDER = "provider" +DLMS_PARSER_VERSION = "2.2.0" + dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter") DlmsMeterComponent = dlms_meter_component_ns.class_( "DlmsMeterComponent", cg.Component, uart.UARTDevice ) +CustomPattern = dlms_meter_component_ns.struct("CustomPattern") +ObisId = cg.global_ns.namespace("dlms_parser").class_("ObisId") -def obis_code(value: Any) -> str: - # Normalize the OBIS code to the strict A.B.C.D.E.F format - bytes_list = parse_obis_code_bytes(value) - return ".".join(str(b) for b in bytes_list) - - -def parse_obis_code_bytes(value: Any) -> list[int]: +def obis_string_to_byte_list(value: Any) -> list[int]: value = cv.string(value) normalized = re.sub(r"[\-\:\*]", ".", value) parts = normalized.split(".") @@ -59,82 +56,51 @@ def parse_obis_code_bytes(value: Any) -> list[int]: return bytes_list +def to_obis_id_struct(value: list[int]) -> cg.Expression: + return ObisId(*value) + + +_request_sensor_slot = cg.slot_counter("DLMS_MAX_SENSORS") +_request_text_sensor_slot = cg.slot_counter("DLMS_MAX_TEXT_SENSORS") +_request_binary_sensor_slot = cg.slot_counter("DLMS_MAX_BINARY_SENSORS") + + +def register_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_sensor_slot(str(hub)) + cg.add(hub.register_sensor(to_obis_id_struct(obis), var)) + + +def register_text_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_text_sensor_slot(str(hub)) + cg.add(hub.register_text_sensor(to_obis_id_struct(obis), var)) + + +def register_binary_sensor(hub: MockObj, obis: list[int], var: MockObj) -> None: + _request_binary_sensor_slot(str(hub)) + cg.add(hub.register_binary_sensor(to_obis_id_struct(obis), var)) + + def custom_pattern_dict(value: Any) -> ConfigType: if isinstance(value, str): return {CONF_PATTERN: value} return value -def validate_custom_pattern(value: ConfigType) -> ConfigType: - if CONF_DEFAULT_OBIS in value and CONF_NAME not in value: - raise cv.Invalid(f"'{CONF_DEFAULT_OBIS}' requires '{CONF_NAME}' to be set") - return value - - -def validate_provider_deprecation(config: ConfigType) -> ConfigType: - if CONF_PROVIDER in config: - provider = str(config[CONF_PROVIDER]).lower() - if provider == "netznoe": - _LOGGER.warning( - "The 'provider: netznoe' option is deprecated and will be removed in 2026.11.0. " - "The required custom patterns have been added automatically for this release, but you must update your configuration.\n" - "Please remove the 'provider' key and explicitly replace it with the following:\n\n" - "custom_patterns:\n" - ' - pattern: "L, TSTR"\n' - ' name: "MeterID"\n' - ' default_obis: "0.0.96.1.0.255"\n' - ' - pattern: "F, TDTM"\n' - ' name: "DateTime"\n' - ' default_obis: "0.0.1.0.0.255"\n' - ) - patterns = config.get(CONF_CUSTOM_PATTERNS, []) - - # Ensure "L, TSTR" for MeterID is present - if not any(p.get(CONF_PATTERN) == "L, TSTR" for p in patterns): - patterns.append( - { - CONF_PATTERN: "L, TSTR", - CONF_NAME: "MeterID", - CONF_DEFAULT_OBIS: [0, 0, 96, 1, 0, 255], - CONF_PRIORITY: 0, - } - ) - - # Ensure "F, TDTM" for DateTime is present - if not any(p.get(CONF_PATTERN) == "F, TDTM" for p in patterns): - patterns.append( - { - CONF_PATTERN: "F, TDTM", - CONF_NAME: "DateTime", - CONF_DEFAULT_OBIS: [0, 0, 1, 0, 0, 255], - CONF_PRIORITY: 0, - } - ) - - config[CONF_CUSTOM_PATTERNS] = patterns - else: - _LOGGER.warning( - "The 'provider' option is deprecated and will be removed in 2026.11.0. " - "The dlms_parser library now handles quirks dynamically. " - "Please remove this option from your configuration." - ) - return config - - CUSTOM_PATTERN_SCHEMA = cv.All( custom_pattern_dict, cv.Schema( { cv.Required(CONF_PATTERN): cv.string, - cv.Optional(CONF_NAME): cv.string, + cv.Optional(CONF_NAME, default="CUSTOM"): cv.string, cv.Optional(CONF_PRIORITY, default=0): cv.int_, - cv.Optional(CONF_DEFAULT_OBIS): parse_obis_code_bytes, + cv.Optional( + CONF_DEFAULT_OBIS, default="0.0.0.0.0.0" + ): obis_string_to_byte_list, } ), - validate_custom_pattern, ) -CONFIG_SCHEMA = cv.All( +CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(DlmsMeterComponent), @@ -142,115 +108,57 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_AUTH_KEY): cv.bind_key(name="Authentication key"), cv.Optional(CONF_CUSTOM_PATTERNS): cv.ensure_list(CUSTOM_PATTERN_SCHEMA), cv.Optional(CONF_SKIP_CRC, default=False): cv.boolean, - cv.Optional(CONF_PROVIDER): cv.string, + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_PROVIDER): cv.invalid( + "The 'provider' option has been removed in ESPHome 2026.11.0.\n" + "For 'provider: netznoe', replace it with:\n\n" + "custom_patterns:\n" + ' - pattern: "L, TSTR"\n' + ' name: "MeterID"\n' + ' default_obis: "0.0.96.1.0.255"\n' + ' - pattern: "F, TDTM"\n' + ' name: "DateTime"\n' + ' default_obis: "0.0.1.0.0.255"\n\n' + "For any other provider, remove the option" + ), cv.Optional( CONF_RECEIVE_TIMEOUT, default="1000ms" ): cv.positive_time_period_milliseconds, } ) .extend(uart.UART_DEVICE_SCHEMA) - .extend(cv.COMPONENT_SCHEMA), - validate_provider_deprecation, + .extend(cv.COMPONENT_SCHEMA) ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema("dlms_meter", require_rx=True) async def to_code(config: ConfigType) -> None: - dec_key_expr = cg.RawExpression("std::nullopt") - if dec_key := config.get(CONF_DECRYPTION_KEY): - key_bytes = [str(int(dec_key[i : i + 2], 16)) for i in range(0, 32, 2)] - dec_key_expr = cg.RawExpression( - f"std::array{{{', '.join(key_bytes)}}}" + custom_patterns = [ + cg.StructInitializer( + CustomPattern, + (CONF_PATTERN, pattern[CONF_PATTERN]), + (CONF_NAME, pattern[CONF_NAME]), + (CONF_PRIORITY, pattern[CONF_PRIORITY]), + (CONF_DEFAULT_OBIS, to_obis_id_struct(pattern[CONF_DEFAULT_OBIS])), ) - - auth_key_expr = cg.RawExpression("std::nullopt") - if auth_key := config.get(CONF_AUTH_KEY): - key_bytes = [str(int(auth_key[i : i + 2], 16)) for i in range(0, 32, 2)] - auth_key_expr = cg.RawExpression( - f"std::array{{{', '.join(key_bytes)}}}" - ) - - patterns = [] - if custom_patterns := config.get(CONF_CUSTOM_PATTERNS): - for p in custom_patterns: - name_expr = cg.RawExpression("std::nullopt") - if name_val := p.get(CONF_NAME): - name_expr = name_val - - if obis_vals := p.get(CONF_DEFAULT_OBIS): - obis_expr = cg.RawExpression( - f"std::array{{{obis_vals[0]}, {obis_vals[1]}, {obis_vals[2]}, {obis_vals[3]}, {obis_vals[4]}, {obis_vals[5]}}}" - ) - else: - obis_expr = cg.RawExpression("std::nullopt") - - patterns.append( - cg.ArrayInitializer( - p[CONF_PATTERN], - name_expr, - p.get(CONF_PRIORITY, 0), - obis_expr, - ) - ) - - patterns_expr = ( - cg.ArrayInitializer(*patterns) if patterns else cg.RawExpression("{}") - ) + for pattern in config.get(CONF_CUSTOM_PATTERNS, []) + ] var = cg.new_Pvariable( config[CONF_ID], config[CONF_RECEIVE_TIMEOUT], config[CONF_SKIP_CRC], - dec_key_expr, - auth_key_expr, - patterns_expr, + config.get(CONF_DECRYPTION_KEY, cg.nullptr), + config.get(CONF_AUTH_KEY, cg.nullptr), + cg.ArrayInitializer(*custom_patterns, multiline=True), ) - hub_id = config[CONF_ID].id - - sensor_count = 0 - for sens_conf in CORE.config.get("sensor", []): - if ( - sens_conf.get("platform") == "dlms_meter" - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - if CONF_OBIS_CODE in sens_conf: - sensor_count += 1 - else: - from .sensor import NUMERIC_KEYS - - sensor_count += sum(1 for key in NUMERIC_KEYS if key in sens_conf) - - text_sensor_count = 0 - for sens_conf in CORE.config.get("text_sensor", []): - if ( - sens_conf.get("platform") == "dlms_meter" - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - if CONF_OBIS_CODE in sens_conf: - text_sensor_count += 1 - else: - from .text_sensor import TEXT_KEYS - - text_sensor_count += sum(1 for key in TEXT_KEYS if key in sens_conf) - - binary_sensor_count = 0 - for sens_conf in CORE.config.get("binary_sensor", []): - if ( - sens_conf.get("platform") == "dlms_meter" - and sens_conf.get(CONF_DLMS_METER_ID).id == hub_id - ): - binary_sensor_count += 1 - - cg.add_define("DLMS_MAX_SENSORS", sensor_count) - cg.add_define("DLMS_MAX_TEXT_SENSORS", text_sensor_count) - cg.add_define("DLMS_MAX_BINARY_SENSORS", binary_sensor_count) - await cg.register_component(var, config) await uart.register_uart_device(var, config) if CORE.is_esp32: - esp32.add_idf_component(name="esphome/dlms_parser", ref="1.1.0") + esp32.add_idf_component(name="esphome/dlms_parser", ref=DLMS_PARSER_VERSION) else: - cg.add_library("esphome/dlms_parser", "1.1.0") + cg.add_library("esphome/dlms_parser", DLMS_PARSER_VERSION) diff --git a/esphome/components/dlms_meter/binary_sensor/__init__.py b/esphome/components/dlms_meter/binary_sensor/__init__.py index a15e58b957..2a51ad0990 100644 --- a/esphome/components/dlms_meter/binary_sensor/__init__.py +++ b/esphome/components/dlms_meter/binary_sensor/__init__.py @@ -3,14 +3,20 @@ from esphome.components import binary_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_binary_sensor, +) DEPENDENCIES = ["dlms_meter"] CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, } ) @@ -18,4 +24,4 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) var = await binary_sensor.new_binary_sensor(config) - cg.add(hub.register_binary_sensor(config[CONF_OBIS_CODE], var)) + register_binary_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/components/dlms_meter/dlms_meter.cpp b/esphome/components/dlms_meter/dlms_meter.cpp index bdbf798df5..32d1817e0e 100644 --- a/esphome/components/dlms_meter/dlms_meter.cpp +++ b/esphome/components/dlms_meter/dlms_meter.cpp @@ -1,11 +1,16 @@ +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) + #include "dlms_meter.h" +#include "esphome/core/application.h" #include "esphome/core/log.h" +#include #include namespace esphome::dlms_meter { -static const char *const TAG = "dlms_meter"; +ESPHOME_LOG_TAG(TAG, "dlms_meter"); static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list args) { std::array buf; vsnprintf(buf.data(), buf.size(), fmt, args); @@ -31,54 +36,40 @@ static void log_callback(dlms_parser::LogLevel level, const char *fmt, va_list a } } -DlmsMeterComponent::DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, - std::optional> decryption_key, - std::optional> authentication_key, +DlmsMeterComponent::DlmsMeterComponent(const uint32_t receive_timeout_ms, const bool skip_crc_check, + const char *decryption_key, const char *authentication_key, std::vector custom_patterns) : receive_timeout_ms_(receive_timeout_ms), skip_crc_check_(skip_crc_check), custom_patterns_(std::move(custom_patterns)), - parser_(&decryptor_) { + parser_([this](const dlms_parser::AxdrCapture &capture) { this->on_data_(capture); }, &this->decryptor_) { dlms_parser::Logger::set_log_function(log_callback); - if (decryption_key.has_value()) { -#ifdef DLMS_METER_NO_CRYPTO - ESP_LOGE(TAG, "Decryption is not supported on this platform (no compatible crypto library found)"); -#else - auto opt_key = dlms_parser::Aes128GcmDecryptionKey::from_bytes(decryption_key.value()); - if (opt_key) { - this->parser_.set_decryption_key(*opt_key); + if (decryption_key != nullptr) { + const auto key = dlms_parser::Aes128GcmDecryptionKey::from_hex(decryption_key); + if (key.has_value()) { + this->parser_.set_decryption_key(*key); } else { ESP_LOGE(TAG, "Failed to set decryption key: invalid key format"); } -#endif } - if (authentication_key.has_value()) { -#ifdef DLMS_METER_NO_CRYPTO - ESP_LOGE(TAG, "Authentication is not supported on this platform (no compatible crypto library found)"); -#else - auto opt_key = dlms_parser::Aes128GcmAuthenticationKey::from_bytes(authentication_key.value()); - if (opt_key) { - this->parser_.set_authentication_key(*opt_key); + if (authentication_key != nullptr) { + const auto key = dlms_parser::Aes128GcmAuthenticationKey::from_hex(authentication_key); + if (key.has_value()) { + this->parser_.set_authentication_key(*key); } else { ESP_LOGE(TAG, "Failed to set authentication key: invalid key format"); } -#endif } this->parser_.set_skip_crc_check(this->skip_crc_check_); this->parser_.load_default_patterns(); - for (const auto &pattern : this->custom_patterns_) { - if (pattern.default_obis.has_value() && pattern.name.has_value()) { - this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority, - pattern.default_obis.value()); - } else if (pattern.name.has_value()) { - this->parser_.register_pattern(pattern.name->c_str(), pattern.pattern.c_str(), pattern.priority); - } else { - this->parser_.register_pattern(pattern.pattern.c_str()); - } + + for (const auto &custom_pattern : this->custom_patterns_) { + this->parser_.register_pattern(custom_pattern.name, custom_pattern.pattern, custom_pattern.priority, + custom_pattern.default_obis); } } @@ -86,39 +77,39 @@ 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_) { - if (pattern.default_obis.has_value() && pattern.name.has_value()) { - const auto &obis = pattern.default_obis.value(); - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %d.%d.%d.%d.%d.%d)", - pattern.pattern.c_str(), pattern.name->c_str(), pattern.priority, obis[0], obis[1], obis[2], - obis[3], obis[4], obis[5]); - } else if (pattern.name.has_value()) { - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d)", pattern.pattern.c_str(), - pattern.name->c_str(), pattern.priority); - } else { - ESP_LOGCONFIG(TAG, " Custom Pattern: '%s'", pattern.pattern.c_str()); - } + std::array obis_buf; + + for (const auto &custom_pattern : this->custom_patterns_) { + const auto default_obis = custom_pattern.default_obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " Custom Pattern: '%s' (name: %s, priority: %d, default_obis: %.*s)", custom_pattern.pattern, + custom_pattern.name, custom_pattern.priority, static_cast(default_obis.size()), + default_obis.data()); } #ifdef USE_SENSOR - for (const auto &entry : this->sensors_) { - LOG_SENSOR(" ", "Numeric Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->sensors_) { + LOG_SENSOR(" ", "Numeric Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif + #ifdef USE_TEXT_SENSOR - for (const auto &entry : this->text_sensors_) { - LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->text_sensors_) { + LOG_TEXT_SENSOR(" ", "Text Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif + #ifdef USE_BINARY_SENSOR - for (const auto &entry : this->binary_sensors_) { - LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", entry.sensor); - ESP_LOGCONFIG(TAG, " OBIS: %s", entry.obis_code.c_str()); + for (const auto &item : this->binary_sensors_) { + LOG_BINARY_SENSOR(" ", "Binary Sensor (OBIS)", item.sensor); + const auto obis = item.obis.to_string(obis_buf); + ESP_LOGCONFIG(TAG, " OBIS: %.*s", static_cast(obis.size()), obis.data()); } #endif } @@ -166,71 +157,73 @@ void DlmsMeterComponent::read_rx_buffer_() { void DlmsMeterComponent::process_frame_() { ESP_LOGV(TAG, "Processing frame of size: %zu bytes", this->bytes_accumulated_); - auto callback = [this](const char *obis_code, float float_val, const char *str_val, bool is_numeric) { - this->on_data_(obis_code, float_val, str_val, is_numeric); - }; - - this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}, callback); + this->parser_.parse({this->rx_buffer_.data(), this->bytes_accumulated_}); this->bytes_accumulated_ = 0; } -void DlmsMeterComponent::on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric) { +void DlmsMeterComponent::on_data_(const dlms_parser::AxdrCapture &capture) { int updated_count = 0; + if (capture.is_numeric()) { +#if defined(USE_SENSOR) || defined(USE_BINARY_SENSOR) + const float value = capture.value_as_float_with_scaler_applied(); +#endif #ifdef USE_SENSOR - if (is_numeric) { - for (auto &item : this->sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(float_val); + for (const auto &item : this->sensors_) { + if (item.obis == capture.obis) { + item.sensor->publish_state(value); updated_count++; } } - } #endif - -#ifdef USE_TEXT_SENSOR - if (!is_numeric && str_val != nullptr) { - for (auto &item : this->text_sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(str_val); - updated_count++; - } - } - } -#endif - #ifdef USE_BINARY_SENSOR - if (is_numeric) { - bool state = float_val != 0.0f; - for (auto &item : this->binary_sensors_) { - if (item.obis_code == obis_code) { - item.sensor->publish_state(state); + for (const auto &item : this->binary_sensors_) { + if (item.obis == capture.obis) { + item.sensor->publish_state(value != 0.0f); updated_count++; } } - } #endif + } else { +#ifdef USE_TEXT_SENSOR + for (const auto &item : this->text_sensors_) { + if (item.obis == capture.obis) { + std::array value_buf; + const auto value = capture.value_as_string(value_buf); + item.sensor->publish_state(value.data(), value.size()); + updated_count++; + } + } +#endif + } if (updated_count == 0) { - ESP_LOGV(TAG, "Received OBIS %s, but no sensors are registered for it.", obis_code); + std::array obis_buf; + const auto obis = capture.obis.to_string(obis_buf); + ESP_LOGV(TAG, "Received OBIS %.*s, but no sensors are registered for it.", static_cast(obis.size()), + obis.data()); } } #ifdef USE_SENSOR -void DlmsMeterComponent::register_sensor(const std::string &obis_code, sensor::Sensor *sensor) { - this->sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_sensor(const dlms_parser::ObisId &obis, sensor::Sensor *sensor) { + this->sensors_.push_back({obis, sensor}); } #endif + #ifdef USE_TEXT_SENSOR -void DlmsMeterComponent::register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor) { - this->text_sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_text_sensor(const dlms_parser::ObisId &obis, text_sensor::TextSensor *sensor) { + this->text_sensors_.push_back({obis, sensor}); } #endif + #ifdef USE_BINARY_SENSOR -void DlmsMeterComponent::register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor) { - this->binary_sensors_.push_back({obis_code, sensor}); +void DlmsMeterComponent::register_binary_sensor(const dlms_parser::ObisId &obis, binary_sensor::BinarySensor *sensor) { + this->binary_sensors_.push_back({obis, sensor}); } #endif } // namespace esphome::dlms_meter + +#endif diff --git a/esphome/components/dlms_meter/dlms_meter.h b/esphome/components/dlms_meter/dlms_meter.h index fc4721843f..2d7898f4d3 100644 --- a/esphome/components/dlms_meter/dlms_meter.h +++ b/esphome/components/dlms_meter/dlms_meter.h @@ -1,9 +1,13 @@ #pragma once +// Ignore Zephyr. It doesn't have any encryption library. +#if defined(USE_ESP32) || defined(USE_ARDUINO) || defined(USE_HOST) + +#include +#include + #include "esphome/core/component.h" #include "esphome/core/defines.h" -#include "esphome/core/application.h" -#include "esphome/core/log.h" #include "esphome/core/helpers.h" #include "esphome/components/uart/uart.h" @@ -19,12 +23,6 @@ #include -#include -#include -#include -#include -#include - #if __has_include() #include #elif !defined(USE_ESP8266) && __has_include() @@ -35,9 +33,12 @@ #elif __has_include() #include #else -#define DLMS_METER_NO_CRYPTO +#error "The platform doesn't provide a compatible encryption library for dlms_parser" #endif +namespace esphome::dlms_meter { + +// Entity storage is sized by codegen; a platform with no entities emits no define #ifndef DLMS_MAX_SENSORS static constexpr uint8_t DLMS_MAX_SENSORS = 0; #endif @@ -48,82 +49,64 @@ static constexpr uint8_t DLMS_MAX_TEXT_SENSORS = 0; static constexpr uint8_t DLMS_MAX_BINARY_SENSORS = 0; #endif -namespace esphome::dlms_meter { - -#ifdef DLMS_METER_NO_CRYPTO -// Fallback dummy decryptor for platforms without supported crypto (e.g., Zephyr during clang-tidy) -class Aes128GcmDecryptorDummy : public dlms_parser::Aes128GcmDecryptor { - public: - void set_decryption_key(const dlms_parser::Aes128GcmDecryptionKey &key) override {} - bool decrypt_in_place(std::span iv, std::span ciphertext_and_plaintext, - std::span aad, std::span tag) override { - return false; - } -}; -#endif - -#if __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; -#elif !defined(USE_ESP8266) && __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; -#elif __has_include() -using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; -#else -using Aes128GcmDecryptorImpl = Aes128GcmDecryptorDummy; -#endif - #ifdef USE_SENSOR struct SensorItem { - std::string obis_code; + dlms_parser::ObisId obis; sensor::Sensor *sensor; }; #endif #ifdef USE_TEXT_SENSOR struct TextSensorItem { - std::string obis_code; + dlms_parser::ObisId obis; text_sensor::TextSensor *sensor; }; #endif #ifdef USE_BINARY_SENSOR struct BinarySensorItem { - std::string obis_code; + dlms_parser::ObisId obis; binary_sensor::BinarySensor *sensor; }; #endif struct CustomPattern { - std::string pattern; - std::optional name; - int priority{0}; - std::optional> default_obis; + const char *pattern; + const char *name; + int priority; + dlms_parser::ObisId default_obis; }; +#if __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorTfPsa; +#elif !defined(USE_ESP8266) && __has_include() +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorMbedTls; +#else +using Aes128GcmDecryptorImpl = dlms_parser::Aes128GcmDecryptorBearSsl; +#endif + class DlmsMeterComponent final : public Component, public uart::UARTDevice { public: - DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, - std::optional> decryption_key, - std::optional> authentication_key, - std::vector custom_patterns); + DlmsMeterComponent(uint32_t receive_timeout_ms, bool skip_crc_check, const char *decryption_key, + const char *authentication_key, std::vector custom_patterns); void setup() override; void dump_config() override; void loop() override; #ifdef USE_SENSOR - void register_sensor(const std::string &obis_code, sensor::Sensor *sensor); + void register_sensor(const dlms_parser::ObisId &obis, sensor::Sensor *sensor); #endif #ifdef USE_TEXT_SENSOR - void register_text_sensor(const std::string &obis_code, text_sensor::TextSensor *sensor); + void register_text_sensor(const dlms_parser::ObisId &obis, text_sensor::TextSensor *sensor); #endif #ifdef USE_BINARY_SENSOR - void register_binary_sensor(const std::string &obis_code, binary_sensor::BinarySensor *sensor); + void register_binary_sensor(const dlms_parser::ObisId &obis, binary_sensor::BinarySensor *sensor); #endif protected: void read_rx_buffer_(); void flush_rx_buffer_(); void process_frame_(); - void on_data_(const char *obis_code, float float_val, const char *str_val, bool is_numeric); + void on_data_(const dlms_parser::AxdrCapture &capture); std::array rx_buffer_; size_t bytes_accumulated_{0}; @@ -149,3 +132,5 @@ class DlmsMeterComponent final : public Component, public uart::UARTDevice { }; } // namespace esphome::dlms_meter + +#endif diff --git a/esphome/components/dlms_meter/sensor/__init__.py b/esphome/components/dlms_meter/sensor/__init__.py index 8ded150cd0..15b9deb012 100644 --- a/esphome/components/dlms_meter/sensor/__init__.py +++ b/esphome/components/dlms_meter/sensor/__init__.py @@ -1,159 +1,65 @@ -import logging - import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import ( - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_VOLTAGE, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL_INCREASING, - UNIT_AMPERE, - UNIT_VOLT, - UNIT_WATT, - UNIT_WATT_HOURS, -) from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code - -_LOGGER = logging.getLogger(__name__) +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_sensor, +) DEPENDENCIES = ["dlms_meter"] -NUMERIC_KEYS = { - "voltage_l1": "1.0.32.7.0.255", - "voltage_l2": "1.0.52.7.0.255", - "voltage_l3": "1.0.72.7.0.255", - "current_l1": "1.0.31.7.0.255", - "current_l2": "1.0.51.7.0.255", - "current_l3": "1.0.71.7.0.255", - "active_power_plus": "1.0.1.7.0.255", - "active_power_minus": "1.0.2.7.0.255", - "active_energy_plus": "1.0.1.8.0.255", - "active_energy_minus": "1.0.2.8.0.255", - "reactive_energy_plus": "1.0.3.8.0.255", - "reactive_energy_minus": "1.0.4.8.0.255", - "power_factor": "1.0.13.7.0.255", +# Removed in 2026.11.0 - kept to provide helpful error message +# Remove before 2027.5.0 +_MEASUREMENT = "state_class: measurement" +_VOLTAGE = f"unit_of_measurement: V, accuracy_decimals: 1, device_class: voltage, {_MEASUREMENT}" +_CURRENT = f"unit_of_measurement: A, accuracy_decimals: 2, device_class: current, {_MEASUREMENT}" +_POWER = ( + f"unit_of_measurement: W, accuracy_decimals: 0, device_class: power, {_MEASUREMENT}" +) +_ENERGY = ( + "unit_of_measurement: Wh, accuracy_decimals: 0, device_class: energy, " + "state_class: total_increasing" +) +_POWER_FACTOR = f"accuracy_decimals: 3, device_class: power_factor, {_MEASUREMENT}" +REMOVED_KEYS = { + "voltage_l1": ("1.0.32.7.0.255", _VOLTAGE), + "voltage_l2": ("1.0.52.7.0.255", _VOLTAGE), + "voltage_l3": ("1.0.72.7.0.255", _VOLTAGE), + "current_l1": ("1.0.31.7.0.255", _CURRENT), + "current_l2": ("1.0.51.7.0.255", _CURRENT), + "current_l3": ("1.0.71.7.0.255", _CURRENT), + "active_power_plus": ("1.0.1.7.0.255", _POWER), + "active_power_minus": ("1.0.2.7.0.255", _POWER), + "active_energy_plus": ("1.0.1.8.0.255", _ENERGY), + "active_energy_minus": ("1.0.2.8.0.255", _ENERGY), + "reactive_energy_plus": ("1.0.3.8.0.255", _ENERGY), + "reactive_energy_minus": ("1.0.4.8.0.255", _ENERGY), + "power_factor": ("1.0.13.7.0.255", _POWER_FACTOR), } -DYNAMIC_SCHEMA = sensor.sensor_schema().extend( + +CONFIG_SCHEMA = sensor.sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, + **{ + cv.Optional(key): cv.invalid( + f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " + f"Add a separate '- platform: dlms_meter' sensor with " + f'obis_code: "{obis}" and {settings}' + ) + for key, (obis, settings) in REMOVED_KEYS.items() + }, } ) -def deprecation_warning(config: ConfigType) -> ConfigType: - _LOGGER.warning( - "The dlms_meter sensor schema using predefined keys (e.g., 'voltage_l1') is deprecated and will be removed in 2026.11.0. " - "Please update your configuration to use the new schema with 'obis_code'." - ) - return config - - -OLD_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("voltage_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("voltage_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_VOLT, - accuracy_decimals=1, - device_class=DEVICE_CLASS_VOLTAGE, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l1"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l2"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("current_l3"): sensor.sensor_schema( - unit_of_measurement=UNIT_AMPERE, - accuracy_decimals=2, - device_class=DEVICE_CLASS_CURRENT, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_power_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT, - accuracy_decimals=0, - device_class=DEVICE_CLASS_POWER, - state_class=STATE_CLASS_MEASUREMENT, - ), - cv.Optional("active_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("active_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_plus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("reactive_energy_minus"): sensor.sensor_schema( - unit_of_measurement=UNIT_WATT_HOURS, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, - ), - cv.Optional("power_factor"): sensor.sensor_schema( - accuracy_decimals=3, - device_class=DEVICE_CLASS_POWER_FACTOR, - state_class=STATE_CLASS_MEASUREMENT, - ), - } - ).extend(cv.COMPONENT_SCHEMA), - deprecation_warning, -) - - -CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) - - async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - - if obis := config.get(CONF_OBIS_CODE): - var = await sensor.new_sensor(config) - cg.add(hub.register_sensor(obis, var)) - else: - for key, obis_val in NUMERIC_KEYS.items(): - if sensor_config := config.get(key): - sens = await sensor.new_sensor(sensor_config) - cg.add(hub.register_sensor(obis_val, sens)) + var = await sensor.new_sensor(config) + register_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/components/dlms_meter/text_sensor/__init__.py b/esphome/components/dlms_meter/text_sensor/__init__.py index c2ff0779ee..4e2e48487a 100644 --- a/esphome/components/dlms_meter/text_sensor/__init__.py +++ b/esphome/components/dlms_meter/text_sensor/__init__.py @@ -1,60 +1,43 @@ -import logging - import esphome.codegen as cg from esphome.components import text_sensor import esphome.config_validation as cv from esphome.types import ConfigType -from .. import CONF_DLMS_METER_ID, CONF_OBIS_CODE, DlmsMeterComponent, obis_code - -_LOGGER = logging.getLogger(__name__) +from .. import ( + CONF_DLMS_METER_ID, + CONF_OBIS_CODE, + DlmsMeterComponent, + obis_string_to_byte_list, + register_text_sensor, +) DEPENDENCIES = ["dlms_meter"] -TEXT_KEYS = { +# Removed in 2026.11.0 - kept to provide helpful error message +# Remove before 2027.5.0 +REMOVED_KEYS = { "timestamp": "0.0.1.0.0.255", "meternumber": "0.0.96.1.0.255", } -DYNAMIC_SCHEMA = text_sensor.text_sensor_schema().extend( + +CONFIG_SCHEMA = text_sensor.text_sensor_schema().extend( { cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Required(CONF_OBIS_CODE): obis_code, + cv.Required(CONF_OBIS_CODE): obis_string_to_byte_list, + **{ + cv.Optional(key): cv.invalid( + f"The predefined '{key}' key was removed in ESPHome 2026.11.0. " + f"Add a separate '- platform: dlms_meter' text sensor with " + f'obis_code: "{obis}" instead' + ) + for key, obis in REMOVED_KEYS.items() + }, } ) -def deprecation_warning(config: ConfigType) -> ConfigType: - _LOGGER.warning( - "The dlms_meter text_sensor schema using predefined keys (e.g., 'timestamp') is deprecated and will be removed in 2026.11.0. " - "Please update your configuration to use the new schema with 'obis_code'." - ) - return config - - -OLD_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(CONF_DLMS_METER_ID): cv.use_id(DlmsMeterComponent), - cv.Optional("timestamp"): text_sensor.text_sensor_schema(), - cv.Optional("meternumber"): text_sensor.text_sensor_schema(), - } - ).extend(cv.COMPONENT_SCHEMA), - deprecation_warning, -) - - -CONFIG_SCHEMA = cv.Any(DYNAMIC_SCHEMA, OLD_SCHEMA) - - async def to_code(config: ConfigType) -> None: hub = await cg.get_variable(config[CONF_DLMS_METER_ID]) - - if obis := config.get(CONF_OBIS_CODE): - var = await text_sensor.new_text_sensor(config) - cg.add(hub.register_text_sensor(obis, var)) - else: - for key, obis_val in TEXT_KEYS.items(): - if text_sensor_config := config.get(key): - sens = await text_sensor.new_text_sensor(text_sensor_config) - cg.add(hub.register_text_sensor(obis_val, sens)) + var = await text_sensor.new_text_sensor(config) + register_text_sensor(hub, config[CONF_OBIS_CODE], var) diff --git a/esphome/components/dps310/__init__.py b/esphome/components/dps310/__init__.py index e69de29bb2..52f2a4105e 100644 --- a/esphome/components/dps310/__init__.py +++ b/esphome/components/dps310/__init__.py @@ -0,0 +1 @@ +DOMAIN = "dps310" diff --git a/esphome/components/dps310/dps310.cpp b/esphome/components/dps310/dps310.cpp index 3b4693139d..080f101747 100644 --- a/esphome/components/dps310/dps310.cpp +++ b/esphome/components/dps310/dps310.cpp @@ -4,7 +4,9 @@ namespace esphome::dps310 { -static const char *const TAG = "dps310"; +ESPHOME_LOG_TAG(TAG, "dps310"); + +static constexpr uint32_t READ_RETRY_TIMEOUT_ID = 0; void DPS310Component::setup() { uint8_t coef_data_raw[DPS310_NUM_COEF_REGS]; @@ -126,7 +128,7 @@ void DPS310Component::read_() { this->update_in_progress_ = false; this->status_clear_warning(); } else { - this->set_timeout("dps310", 10, [this]() { this->read_(); }); + this->set_timeout(READ_RETRY_TIMEOUT_ID, 10, [this]() { this->read_(); }); } } 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/__init__.py b/esphome/components/ds1307/__init__.py index e69de29bb2..fa275ded95 100644 --- a/esphome/components/ds1307/__init__.py +++ b/esphome/components/ds1307/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ds1307" diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 1d04b52168..4ddef04f41 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -6,7 +6,7 @@ namespace esphome::ds1307 { -static const char *const TAG = "ds1307"; +ESPHOME_LOG_TAG(TAG, "ds1307"); void DS1307Component::setup() { if (!this->read_rtc_()) { 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/ds1603l/__init__.py b/esphome/components/ds1603l/__init__.py index e69de29bb2..81c65f4fd3 100644 --- a/esphome/components/ds1603l/__init__.py +++ b/esphome/components/ds1603l/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ds1603l" diff --git a/esphome/components/ds1603l/ds1603l.cpp b/esphome/components/ds1603l/ds1603l.cpp index b0b0ef8175..7762a593f1 100644 --- a/esphome/components/ds1603l/ds1603l.cpp +++ b/esphome/components/ds1603l/ds1603l.cpp @@ -7,7 +7,7 @@ namespace esphome::ds1603l { -static const char *const TAG = "ds1603l.sensor"; +ESPHOME_LOG_TAG(TAG, "ds1603l.sensor"); void DS1603L::loop() { // Assemble frames one byte at a time so a stream that starts mid-frame can realign diff --git a/esphome/components/ds2484/__init__.py b/esphome/components/ds2484/__init__.py index 3d9f24ff19..f101dedc01 100644 --- a/esphome/components/ds2484/__init__.py +++ b/esphome/components/ds2484/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@mrk-its"] +DOMAIN = "ds2484" diff --git a/esphome/components/ds2484/ds2484.cpp b/esphome/components/ds2484/ds2484.cpp index 69103ccbd2..5519848196 100644 --- a/esphome/components/ds2484/ds2484.cpp +++ b/esphome/components/ds2484/ds2484.cpp @@ -1,7 +1,7 @@ #include "ds2484.h" namespace esphome::ds2484 { -static const char *const TAG = "ds2484.onewire"; +ESPHOME_LOG_TAG(TAG, "ds2484.onewire"); void DS2484OneWireBus::setup() { this->reset_device(); diff --git a/esphome/components/ds248x/__init__.py b/esphome/components/ds248x/__init__.py index a2e2a87ed0..3a2cb34747 100644 --- a/esphome/components/ds248x/__init__.py +++ b/esphome/components/ds248x/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE from esphome.types import ConfigType CODEOWNERS = ["@tomwellnitz"] +DOMAIN = "ds248x" MULTI_CONF = True DEPENDENCIES = ["i2c"] diff --git a/esphome/components/ds248x/ds248x.cpp b/esphome/components/ds248x/ds248x.cpp index c8f7395119..db104b3d15 100644 --- a/esphome/components/ds248x/ds248x.cpp +++ b/esphome/components/ds248x/ds248x.cpp @@ -4,7 +4,7 @@ namespace esphome::ds248x { -static const char *const TAG = "ds248x"; +ESPHOME_LOG_TAG(TAG, "ds248x"); void DS248xComponent::setup() { ESP_LOGCONFIG(TAG, "Setting up DS248x..."); diff --git a/esphome/components/ds248x/ds248x_one_wire_bus.cpp b/esphome/components/ds248x/ds248x_one_wire_bus.cpp index ed5b05bab7..201261d4bf 100644 --- a/esphome/components/ds248x/ds248x_one_wire_bus.cpp +++ b/esphome/components/ds248x/ds248x_one_wire_bus.cpp @@ -4,7 +4,7 @@ namespace esphome::ds248x { -static const char *const TAG = "ds248x.one_wire"; +ESPHOME_LOG_TAG(TAG, "ds248x.one_wire"); void DS248xOneWireBus::setup() { ESP_LOGCONFIG(TAG, "Setting up DS248x 1-Wire Bus (Channel %d)...", this->channel_); diff --git a/esphome/components/ds3231/__init__.py b/esphome/components/ds3231/__init__.py new file mode 100644 index 0000000000..262c951e80 --- /dev/null +++ b/esphome/components/ds3231/__init__.py @@ -0,0 +1,35 @@ +from esphome import automation +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 = ["@linkedupbits"] +DEPENDENCIES = ["i2c"] +MULTI_CONF = True +DOMAIN = "ds3231" + +CONF_DS3231_ID = "ds3231_id" + +ds3231_ns = cg.esphome_ns.namespace("ds3231") +DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)}) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x68)) +) + + +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) + + +automation.register_apply_action( + "ds3231.force_temperature_conversion", + automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}), + automation.ApplyCall("force_temperature_conversion()"), +) diff --git a/esphome/components/ds3231/ds3231.cpp b/esphome/components/ds3231/ds3231.cpp new file mode 100644 index 0000000000..4f7396fcbd --- /dev/null +++ b/esphome/components/ds3231/ds3231.cpp @@ -0,0 +1,82 @@ +#include "ds3231.h" +#include "esphome/core/log.h" + +// Datasheet: +// - https://www.analog.com/media/en/technical-documentation/data-sheets/DS3231.pdf + +namespace esphome::ds3231 { + +ESPHOME_LOG_TAG(TAG, "ds3231"); + +static constexpr uint8_t DS3231_REG_CONTROL = 0x0E; +static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11; + +void DS3231Component::setup() { + if (!this->read_control_status_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery + // INT/SQW is the (unused) interrupt line rather than a square-wave output. + this->control_reg_ |= CONTROL_INTCN; + if (!this->write_control_()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } +} + +void DS3231Component::dump_config() { + ESP_LOGCONFIG(TAG, "DS3231:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } +} + +bool DS3231Component::read_temperature(float &out) { + uint8_t raw[2]; + if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) { + return false; + } + // 10-bit two's complement, 0.25 degC per LSB. + int16_t value = static_cast((raw[0] << 8) | raw[1]) >> 6; + out = value * 0.25f; + return true; +} + +void DS3231Component::force_temperature_conversion() { + // Just kick off the conversion - the chip clears CONV and updates the temperature + // register on its own within ~200 ms, and the next temperature poll picks it up. + // Blocking here to wait for it would stall the main loop. + if (this->is_failed() || !this->read_control_status_()) + return; + if ((this->status_reg_ & STATUS_BSY) != 0) { + ESP_LOGW(TAG, "A temperature conversion is already in progress."); + return; + } + this->control_reg_ |= CONTROL_CONV; + this->write_control_(); +} + +bool DS3231Component::read_control_status_() { + uint8_t raw[2]; + if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) { + ESP_LOGW(TAG, "Can't read control/status registers."); + return false; + } + this->control_reg_ = raw[0]; + this->status_reg_ = raw[1]; + return true; +} + +bool DS3231Component::write_control_() { + if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) { + ESP_LOGW(TAG, "Can't write control register."); + return false; + } + return true; +} + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/ds3231.h b/esphome/components/ds3231/ds3231.h new file mode 100644 index 0000000000..b8a81a2e49 --- /dev/null +++ b/esphome/components/ds3231/ds3231.h @@ -0,0 +1,32 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::ds3231 { + +/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the +/// chip through this class. +class DS3231Component final : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + + bool read_temperature(float &out); + void force_temperature_conversion(); + + protected: + static constexpr uint8_t CONTROL_INTCN = 0x04; + static constexpr uint8_t CONTROL_CONV = 0x20; + static constexpr uint8_t CONTROL_EOSC = 0x80; + + static constexpr uint8_t STATUS_BSY = 0x04; + + bool read_control_status_(); + bool write_control_(); + + uint8_t control_reg_{0}; + uint8_t status_reg_{0}; +}; + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/sensor/__init__.py b/esphome/components/ds3231/sensor/__init__.py new file mode 100644 index 0000000000..a92fc1f48c --- /dev/null +++ b/esphome/components/ds3231/sensor/__init__.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.types import ConfigType + +from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns + +DEPENDENCIES = ["ds3231"] + +DS3231TemperatureSensor = ds3231_ns.class_( + "DS3231TemperatureSensor", + sensor.Sensor, + cg.PollingComponent, + cg.Parented.template(DS3231Component), +) + + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + DS3231TemperatureSensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + icon=ICON_THERMOMETER, + ) + .extend({cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component)}) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_DS3231_ID]) diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.cpp b/esphome/components/ds3231/sensor/ds3231_temperature.cpp new file mode 100644 index 0000000000..8d7e1156be --- /dev/null +++ b/esphome/components/ds3231/sensor/ds3231_temperature.cpp @@ -0,0 +1,23 @@ +#include "ds3231_temperature.h" +#include "esphome/core/log.h" + +namespace esphome::ds3231 { + +ESPHOME_LOG_TAG(TAG, "ds3231.sensor"); + +void DS3231TemperatureSensor::update() { + float temperature; + if (!this->parent_->read_temperature(temperature)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + this->publish_state(temperature); +} + +void DS3231TemperatureSensor::dump_config() { + LOG_SENSOR("", "DS3231 Temperature", this); + LOG_UPDATE_INTERVAL(this); +} + +} // namespace esphome::ds3231 diff --git a/esphome/components/ds3231/sensor/ds3231_temperature.h b/esphome/components/ds3231/sensor/ds3231_temperature.h new file mode 100644 index 0000000000..aa31c42438 --- /dev/null +++ b/esphome/components/ds3231/sensor/ds3231_temperature.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/sensor/sensor.h" + +#include "../ds3231.h" + +namespace esphome::ds3231 { + +class DS3231TemperatureSensor final : public sensor::Sensor, public PollingComponent, public Parented { + public: + void update() override; + void dump_config() override; +}; + +} // namespace esphome::ds3231 diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 96a1e75668..4ba9d1696a 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -3,6 +3,7 @@ import logging from esphome import pins import esphome.codegen as cg from esphome.components import uart +from esphome.components.uart import DOMAIN as UART_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -16,6 +17,7 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@PolarGoose"] +DOMAIN = "dsmr" MULTI_CONF = True @@ -85,13 +87,13 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) - cg.add_library("esphome/dsmr_parser", "1.9.0") + cg.add_library("esphome/dsmr_parser", "1.10.0") def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() - for uart_conf in full_config["uart"]: + for uart_conf in full_config[UART_DOMAIN]: if uart_conf[CONF_ID] == config[CONF_UART_ID]: rx_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE] if rx_buffer_size < 1500: diff --git a/esphome/components/dsmr/sensor.py b/esphome/components/dsmr/sensor.py index 6aecc62d6b..c8d12c326b 100644 --- a/esphome/components/dsmr/sensor.py +++ b/esphome/components/dsmr/sensor.py @@ -97,6 +97,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("energy_delivered_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_delivered_tariff1_ch"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -109,6 +115,12 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + cv.Optional("energy_returned_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT_HOURS, + accuracy_decimals=3, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_returned_tariff1_ch"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -121,6 +133,77 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_ENERGY, state_class=STATE_CLASS_TOTAL_INCREASING, ), + # Reactive energy totals and per-quadrant (Q1-Q4) values (Switzerland, e.g. EWL Luzern AM550) + cv.Optional("total_imported_energy_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("total_exported_energy_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q1_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q2_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q3_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_tariff1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional("reactive_energy_q4_tariff2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + accuracy_decimals=3, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), cv.Optional("energy_delivered_tariff1_il"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOWATT_HOURS, accuracy_decimals=3, @@ -231,6 +314,91 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + # Per-phase active power and maximum demand (Switzerland, e.g. EWL Luzern AM550) + cv.Optional("power_delivered_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_delivered_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_delivered_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("power_returned_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_delivered_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l1_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l2_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("reactive_power_returned_l3_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, + accuracy_decimals=3, + device_class=DEVICE_CLASS_REACTIVE_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("maximum_power_delivered_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional("maximum_power_returned_ch"): sensor.sensor_schema( + unit_of_measurement=UNIT_KILOWATT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), cv.Optional("reactive_power_delivered"): sensor.sensor_schema( unit_of_measurement=UNIT_KILOVOLT_AMPS_REACTIVE, accuracy_decimals=3, diff --git a/esphome/components/duty_cycle/__init__.py b/esphome/components/duty_cycle/__init__.py index e69de29bb2..8d11f04488 100644 --- a/esphome/components/duty_cycle/__init__.py +++ b/esphome/components/duty_cycle/__init__.py @@ -0,0 +1 @@ +DOMAIN = "duty_cycle" diff --git a/esphome/components/duty_cycle/duty_cycle_sensor.cpp b/esphome/components/duty_cycle/duty_cycle_sensor.cpp index fd0a48b935..69a41eb548 100644 --- a/esphome/components/duty_cycle/duty_cycle_sensor.cpp +++ b/esphome/components/duty_cycle/duty_cycle_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::duty_cycle { -static const char *const TAG = "duty_cycle"; +ESPHOME_LOG_TAG(TAG, "duty_cycle"); void DutyCycleSensor::setup() { this->pin_->setup(); diff --git a/esphome/components/duty_time/__init__.py b/esphome/components/duty_time/__init__.py index b708cee80b..258253e4eb 100644 --- a/esphome/components/duty_time/__init__.py +++ b/esphome/components/duty_time/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@dudanov"] +DOMAIN = "duty_time" diff --git a/esphome/components/duty_time/duty_time_sensor.cpp b/esphome/components/duty_time/duty_time_sensor.cpp index 697a4e96f3..646c2febc6 100644 --- a/esphome/components/duty_time/duty_time_sensor.cpp +++ b/esphome/components/duty_time/duty_time_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::duty_time_sensor { -static const char *const TAG = "duty_time_sensor"; +ESPHOME_LOG_TAG(TAG, "duty_time_sensor"); #ifdef USE_BINARY_SENSOR void DutyTimeSensor::set_sensor(binary_sensor::BinarySensor *const sensor) { 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/e131/__init__.py b/esphome/components/e131/__init__.py index 3b1eb99e60..2d48f5a593 100644 --- a/esphome/components/e131/__init__.py +++ b/esphome/components/e131/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components import socket from esphome.components.light.effects import register_addressable_effect from esphome.components.light.types import AddressableLightEffect import esphome.config_validation as cv @@ -9,6 +10,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["socket"] DEPENDENCIES = ["network"] +DOMAIN = "e131" e131_ns = cg.esphome_ns.namespace("e131") E131AddressableLightEffect = e131_ns.class_( @@ -27,12 +29,17 @@ CHANNELS = { CONF_UNIVERSE = "universe" CONF_E131_ID = "e131_id" -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(E131Component), - cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(*METHODS, upper=True), - } -).extend(cv.COMPONENT_SCHEMA) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(E131Component), + cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of( + *METHODS, upper=True + ), + } + ).extend(cv.COMPONENT_SCHEMA), + socket.consume_sockets(1, "e131", socket.SocketType.UDP), +) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index 061af4c4c0..1d847963fe 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -1,30 +1,31 @@ #include "e131.h" #ifdef USE_NETWORK #include "e131_addressable_light_effect.h" +#include "esphome/components/network/util.h" #include "esphome/core/log.h" #include namespace esphome::e131 { -static const char *const TAG = "e131"; +ESPHOME_LOG_TAG(TAG, "e131"); static const int PORT = 5568; E131Component::E131Component() {} E131Component::~E131Component() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) if (this->socket_) { this->socket_->close(); } -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - this->udp_.stop(); -#endif } void E131Component::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - this->socket_ = socket::socket_ip(SOCK_DGRAM, IPPROTO_IP); + this->socket_ = socket::socket_ip_udp(IPPROTO_IP); + if (!this->socket_) { + ESP_LOGW(TAG, "Could not create socket: errno %d", errno); + this->mark_failed(); + return; + } int enable = 1; int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); @@ -54,18 +55,13 @@ void E131Component::setup() { this->mark_failed(); return; } -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - if (!this->udp_.begin(PORT)) { - ESP_LOGW(TAG, "Cannot bind E1.31 to port %d.", PORT); - this->mark_failed(); - return; - } -#endif join_igmp_groups_(); } void E131Component::loop() { + if (this->igmp_join_pending_ && network::is_connected()) + this->join_igmp_groups_(); E131Packet packet; int universe = 0; uint8_t buf[1460]; @@ -74,7 +70,7 @@ void E131Component::loop() { // Drain all queued packets so multi-universe frames are applied // atomically before the light writes. Without this, each universe // packet would trigger a separate full-strip write causing tearing. - while ((len = this->read_(buf, sizeof(buf))) > 0) { + while ((len = this->socket_->read(buf, sizeof(buf))) > 0) { if (!this->packet_(buf, (size_t) len, universe, packet)) { ESP_LOGV(TAG, "Invalid packet received of size %d.", (int) len); continue; diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index b0a8b4f83f..7d6983ddc3 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -1,11 +1,7 @@ #pragma once #include "esphome/core/defines.h" #ifdef USE_NETWORK -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) -#include -#endif #include "esphome/core/component.h" #include @@ -45,30 +41,17 @@ class E131Component final : public esphome::Component { void set_method(E131ListenMethod listen_method) { this->listen_method_ = listen_method; } protected: - inline ssize_t read_(uint8_t *buf, size_t len) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - return this->socket_->read(buf, len); -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - if (!this->udp_.parsePacket()) - return -1; - return this->udp_.read(buf, len); -#else - return -1; -#endif - } bool packet_(const uint8_t *data, size_t len, int &universe, E131Packet &packet); bool process_(int universe, const E131Packet &packet); - bool join_igmp_groups_(); + void join_igmp_groups_(); + void join_universe_(uint16_t universe); UniverseConsumer *find_universe_(int universe); void join_(int universe); void leave_(int universe); E131ListenMethod listen_method_{E131_MULTICAST}; -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr socket_; -#elif defined(USE_SOCKET_IMPL_LWIP_TCP) - WiFiUDP udp_; -#endif + bool igmp_join_pending_{false}; + std::unique_ptr socket_; std::vector light_effects_; std::vector universe_consumers_; }; diff --git a/esphome/components/e131/e131_addressable_light_effect.cpp b/esphome/components/e131/e131_addressable_light_effect.cpp index 6157eba4e9..c8940052fd 100644 --- a/esphome/components/e131/e131_addressable_light_effect.cpp +++ b/esphome/components/e131/e131_addressable_light_effect.cpp @@ -5,7 +5,7 @@ namespace esphome::e131 { -static const char *const TAG = "e131_addressable_light_effect"; +ESPHOME_LOG_TAG(TAG, "e131_addressable_light_effect"); static const int MAX_DATA_SIZE = (sizeof(E131Packet::values) - 1); E131AddressableLightEffect::E131AddressableLightEffect(const char *name) : AddressableLightEffect(name) {} diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index afed2abe31..14e3b64133 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -3,6 +3,7 @@ #include "e131.h" #ifdef USE_NETWORK #include "esphome/components/network/ip_address.h" +#include "esphome/components/network/util.h" #include "esphome/core/log.h" #include "esphome/core/util.h" #include "esphome/core/helpers.h" @@ -14,7 +15,7 @@ namespace esphome::e131 { -static const char *const TAG = "e131"; +ESPHOME_LOG_TAG(TAG, "e131"); static const uint8_t ACN_ID[12] = {0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00}; static const uint32_t VECTOR_ROOT = 4; @@ -59,33 +60,35 @@ union E131RawPacket { // Get the offset of `property_values[1]` const size_t E131_MIN_PACKET_SIZE = offsetof(E131RawPacket, property_values) + sizeof(uint8_t); -bool E131Component::join_igmp_groups_() { - if (this->listen_method_ != E131_MULTICAST) - return false; -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - if (this->socket_ == nullptr) - return false; -#endif - +void E131Component::join_igmp_groups_() { + if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr) + return; + // Joined from loop() once the network is up; RP2040 has no interface to join before then + if (!network::is_connected()) { + this->igmp_join_pending_ = true; + return; + } + this->igmp_join_pending_ = false; for (auto &entry : this->universe_consumers_) { - if (!entry.consumers) - continue; + if (entry.consumers) + this->join_universe_(entry.universe); + } +} - ip4_addr_t multicast_addr = - network::IPAddress(239, 255, ((entry.universe >> 8) & 0xff), ((entry.universe >> 0) & 0xff)); +void E131Component::join_universe_(uint16_t universe) { + ip4_addr_t multicast_addr = network::IPAddress(239, 255, ((universe >> 8) & 0xff), ((universe >> 0) & 0xff)); - err_t err; - { - LwIPLock lock; - err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr); - } - - if (err) { - ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", entry.universe); - } + err_t err; + { + LwIPLock lock; + err = igmp_joingroup(IP4_ADDR_ANY4, &multicast_addr); } - return true; + if (err) { + ESP_LOGW(TAG, "IGMP join for %d universe of E1.31 failed. Multicast might not work.", universe); + } else { + ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe); + } } UniverseConsumer *E131Component::find_universe_(int universe) { @@ -107,9 +110,15 @@ void E131Component::join_(int universe) { this->universe_consumers_.push_back({static_cast(universe), 1}); } - if (this->join_igmp_groups_()) { - ESP_LOGD(TAG, "Joined %d universe for E1.31.", universe); + if (this->listen_method_ != E131_MULTICAST || this->socket_ == nullptr) + return; + // A pending batch join covers this universe too; joining it here as well would break leave_() + if (this->igmp_join_pending_ || !network::is_connected()) { + ESP_LOGD(TAG, "IGMP join for %d universe of E1.31 deferred until the network is up.", universe); + this->igmp_join_pending_ = true; + return; } + this->join_universe_(universe); } void E131Component::leave_(int universe) { diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py new file mode 100644 index 0000000000..8a0e165418 --- /dev/null +++ b/esphome/components/ecocomfort2/__init__.py @@ -0,0 +1,75 @@ +import esphome.codegen as cg +from esphome.components import ble_client, esp32_ble, time +from esphome.components.esp32_ble import ( + CONF_AUTH_REQ_MODE, + CONF_IO_CAPABILITY, + CONF_MAX_KEY_SIZE, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TIME_ID +from esphome.cpp_generator import MockObj +import esphome.final_validate as fv +from esphome.types import ConfigType + +DOMAIN = "ecocomfort2" +CONF_ECOCOMFORT2_ID = "ecocomfort2_id" + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ble_client"] +MULTI_CONF = True + +ecocomfort2_ns = cg.esphome_ns.namespace(DOMAIN) +Ecocomfort2Hub = ecocomfort2_ns.class_( + "Ecocomfort2Hub", ble_client.BLEClientNode, cg.PollingComponent +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Ecocomfort2Hub), + cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + +ECOCOMFORT2_CLIENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ECOCOMFORT2_ID): cv.use_id(Ecocomfort2Hub), + } +) + +_request_child_slot = cg.slot_counter("ECOCOMFORT2_CHILD_COUNT") + + +async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + _request_child_slot(str(parent)) + cg.add(parent.register_child(var)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await ble_client.register_ble_node(var, config) + if (time_id := config.get(CONF_TIME_ID)) is not None: + cg.add(var.set_time(await cg.get_variable(time_id))) + + +def _final_validate(config: ConfigType) -> ConfigType: + # The unit only talks over an encrypted, bonded link + ble_conf = fv.full_config.get()[esp32_ble.DOMAIN] + if ( + ble_conf[CONF_IO_CAPABILITY] != "none" + or ble_conf.get(CONF_AUTH_REQ_MODE) != "sc_bond" + or ble_conf.get(CONF_MAX_KEY_SIZE) != 16 + ): + raise cv.Invalid( + "ecocomfort2 needs esp32_ble configured with io_capability: none, " + "auth_req_mode: sc_bond and max_key_size: 16" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ecocomfort2/binary_sensor.py b/esphome/components/ecocomfort2/binary_sensor.py new file mode 100644 index 0000000000..15e5b69d2d --- /dev/null +++ b/esphome/components/ecocomfort2/binary_sensor.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_CONNECTED +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONF_BOOST = "boost" + +CONFIG_SCHEMA = cv.All( + ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Optional(CONF_CONNECTED): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BOOST): binary_sensor.binary_sensor_schema(), + } + ), + cv.has_at_least_one_key(CONF_CONNECTED, CONF_BOOST), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_CONNECTED, hub.set_connected_binary_sensor) + await sensors(CONF_BOOST, hub.set_boost_binary_sensor) diff --git a/esphome/components/ecocomfort2/ecocomfort2_child.h b/esphome/components/ecocomfort2/ecocomfort2_child.h new file mode 100644 index 0000000000..b6d2fc87cb --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_child.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/helpers.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Hub; + +class Ecocomfort2Client : public Parented { + public: + /// Called after each successful C_OPER readback. + virtual void on_status() = 0; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp new file mode 100644 index 0000000000..c9d64892c4 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -0,0 +1,146 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_fan.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2.fan"); + +static constexpr int SPEED_COUNT = SPEED_VEL3; + +struct Preset { + const char *name; + uint8_t mode; + bool auto_mode; +}; + +// Order matters: the fan core persists the preset as an index into this list. +static constexpr Preset PRESETS[] = { + {"In", OPER_IN, false}, + {"Out", OPER_OUT, false}, + {"In-Out", OPER_IN_OUT, false}, + {"Sensor", OPER_SENSOR_OR_AUTO, false}, + {"Auto", OPER_SENSOR_OR_AUTO, true}, +}; +static constexpr const Preset &PRESET_IN_OUT = PRESETS[2]; +static constexpr const Preset &PRESET_SENSOR = PRESETS[3]; + +static const Preset *find_preset(const char *name) { + for (const auto &preset : PRESETS) { + if (preset.name == name) + return &preset; + } + return nullptr; +} + +static const Preset *find_preset(uint8_t mode, bool auto_mode) { + for (const auto &preset : PRESETS) { + if (preset.mode == mode && preset.auto_mode == auto_mode) + return &preset; + } + return nullptr; +} + +void Ecocomfort2Fan::setup() { + this->set_supported_preset_modes( + {PRESETS[0].name, PRESETS[1].name, PRESETS[2].name, PRESETS[3].name, PRESETS[4].name}); + // Restore the last preset and speed so turning on resumes them; on/off comes from the device. + auto restore = this->restore_state_(); + if (restore.has_value()) { + this->speed = restore->speed; + if (restore->preset_mode < std::size(PRESETS)) + this->set_preset_mode_(PRESETS[restore->preset_mode].name); + } +} + +void Ecocomfort2Fan::dump_config() { LOG_FAN("", "Ecocomfort2 Fan", this); } + +fan::FanTraits Ecocomfort2Fan::get_traits() { + fan::FanTraits traits; + traits.set_speed(true); + traits.set_supported_speed_count(SPEED_COUNT); + this->wire_preset_modes_(traits); + return traits; +} + +void Ecocomfort2Fan::control(const fan::FanCall &call) { + if (!this->parent_->is_ready()) { + ESP_LOGW(TAG, "Not ready, cannot handle control call"); + return; + } + + // Any call that does not turn the fan off turns it on + auto state = call.get_state(); + this->state = !state.has_value() || *state; + // The core clears the preset in a speed call; here the preset is the air flow mode, so keep it. + if (call.has_preset_mode()) + this->set_preset_mode_(call.get_preset_mode()); + + const Preset *preset = find_preset(this->get_preset_mode().c_str()); + if (preset == nullptr) { + preset = &PRESET_IN_OUT; + this->set_preset_mode_(preset->name); + } + auto speed = call.get_speed(); + if (speed.has_value()) { + this->speed = *speed; + // A manual speed leaves automatic speed control + if (preset->auto_mode) { + preset = &PRESET_SENSOR; + this->set_preset_mode_(preset->name); + } + } + this->speed = clamp(this->speed, 1, SPEED_COUNT); + + if (!this->state) { + this->parent_->write_oper(OPER_OFF, SPEED_OFF); + } else { + this->parent_->write_oper(preset->mode, preset->auto_mode ? FLAG_AUTO : static_cast(this->speed)); + } + this->publish_state(); +} + +void Ecocomfort2Fan::on_status() { + uint8_t mode = this->parent_->get_mode(); + uint8_t raw_speed = this->parent_->get_speed(); + + // Off keeps the preset and speed so turning on resumes them + if (mode == OPER_OFF) { + if (!this->state) + return; + this->state = false; + this->publish_state(); + return; + } + + int new_speed = this->speed; + uint8_t base_speed = raw_speed & SPEED_MASK; + if (raw_speed & FLAG_BOOST) { + new_speed = SPEED_COUNT; + } else if (raw_speed & FLAG_NIGHT) { + new_speed = SPEED_SLEEP; + } else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) { + new_speed = base_speed; + } + const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0); + const char *new_preset = preset != nullptr ? preset->name : nullptr; + bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset; + if (this->state && this->speed == new_speed && same_preset) + return; + + this->state = true; + this->speed = new_speed; + if (new_preset != nullptr) { + this->set_preset_mode_(new_preset); + } else { + this->clear_preset_mode_(); + } + this->publish_state(); +} + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.h b/esphome/components/ecocomfort2/ecocomfort2_fan.h new file mode 100644 index 0000000000..81af55699f --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/fan/fan.h" +#include "ecocomfort2_child.h" +#include "ecocomfort2_hub.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Fan : public fan::Fan, public Ecocomfort2Client, public Component { + public: + void setup() override; + void dump_config() override; + fan::FanTraits get_traits() override; + void on_status() override; + + protected: + void control(const fan::FanCall &call) override; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp new file mode 100644 index 0000000000..00ce0f2080 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -0,0 +1,323 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_hub.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2"); + +void Ecocomfort2Hub::setup() { +#ifdef USE_BINARY_SENSOR + if (this->connected_binary_sensor_ != nullptr) + this->connected_binary_sensor_->publish_initial_state(false); +#endif +#ifdef USE_TIME + // The clock is written after each time-source sync + if (this->time_ != nullptr) + this->time_->add_on_time_sync_callback([this]() { this->sync_clock_(); }); +#endif +} + +void Ecocomfort2Hub::update() { + if (!this->is_ready()) { + ESP_LOGD(TAG, "[%s] Not ready, skipping update", this->parent_->address_str()); + return; + } + // The GATT client runs the requests one after the other + this->read_char_(this->char_handle_state_); + this->read_char_(this->char_handle_oper_); +} + +void Ecocomfort2Hub::read_char_(uint16_t handle) { + auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), handle, + ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] Read request for handle 0x%x failed, status=%d", this->parent_->address_str(), handle, status); + } +} + +void Ecocomfort2Hub::dump_config() { + ESP_LOGCONFIG(TAG, + "Ecocomfort2 Hub:\n" + " Address: %s", + this->parent_->address_str()); + LOG_UPDATE_INTERVAL(this); +#ifdef USE_TIME + ESP_LOGCONFIG(TAG, " Clock sync: %s", YESNO(this->time_ != nullptr)); +#endif +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Humidity", this->humidity_sensor_); + LOG_SENSOR(" ", "VOC", this->voc_sensor_); + LOG_SENSOR(" ", "Direction", this->direction_sensor_); +#endif +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Connected", this->connected_binary_sensor_); + LOG_BINARY_SENSOR(" ", "Boost", this->boost_binary_sensor_); +#endif +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Firmware", this->firmware_text_sensor_); +#endif +} + +void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + ESP_LOGD(TAG, "[%s] Disconnected: reason=%d", this->parent_->address_str(), param->disconnect.reason); + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->cancel_timeout(READBACK_TIMEOUT_ID); + this->status_set_warning(); + this->char_handle_oper_ = 0; + this->char_handle_state_ = 0; + this->char_handle_info_ = 0; + this->char_handle_clock_ = 0; + this->set_connected_(false); + this->publish_unknown_(); + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + const auto service_uuid = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); + const auto oper_uuid = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); + const auto state_uuid = espbt::ESPBTUUID::from_raw("438d3433-7e5a-459a-a8e4-66343fad2bb0"); + const auto info_uuid = espbt::ESPBTUUID::from_raw("f5f56229-dd4f-480f-a829-9189269d8b37"); + const auto clock_uuid = espbt::ESPBTUUID::from_raw("82788997-49e4-4533-b949-7ed433678044"); + // Keep the handles, not the characteristics: ble_client frees its services once all nodes are established. + auto *oper = this->parent_->get_characteristic(service_uuid, oper_uuid); + auto *state = this->parent_->get_characteristic(service_uuid, state_uuid); + if (oper == nullptr || state == nullptr) { + ESP_LOGW(TAG, "[%s] C_OPER or C_STATE characteristic not found, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->char_handle_oper_ = oper->handle; + this->char_handle_state_ = state->handle; + // The version and clock characteristics only feed optional features, so a unit without them still works + auto *info = this->parent_->get_characteristic(service_uuid, info_uuid); + this->char_handle_info_ = info != nullptr ? info->handle : 0; + auto *clock = this->parent_->get_characteristic(service_uuid, clock_uuid); + this->char_handle_clock_ = clock != nullptr ? clock->handle : 0; + this->node_state = espbt::ClientState::ESTABLISHED; + this->status_set_warning(); + + // A bonded device may already have re-encrypted the link before discovery finished. + if (this->parent_->is_paired()) { + this->on_paired_(); + break; + } + ESP_LOGI(TAG, "[%s] Requesting BLE encryption", this->parent_->address_str()); + if (this->parent_->pair() != ESP_OK) { + ESP_LOGW(TAG, "[%s] BLE pair request failed, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->set_timeout(AUTH_TIMEOUT_ID, AUTH_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "[%s] BLE authentication timed out, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + }); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (param->read.conn_id != this->parent_->get_conn_id()) { + break; + } + const uint16_t handle = param->read.handle; + if (handle == this->char_handle_oper_) { + if (this->read_ok_(param)) + this->on_oper_(param->read.value, param->read.value_len); + } else if (handle == this->char_handle_state_) { + if (this->read_ok_(param)) + this->on_state_(param->read.value, param->read.value_len); + } else if (handle == this->char_handle_info_) { + if (this->read_ok_(param)) + this->on_info_(param->read.value, param->read.value_len); + } else { + break; + } + break; + } + default: + break; + } +} + +bool Ecocomfort2Hub::read_ok_(const esp_ble_gattc_cb_param_t *param) { + if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { + // ble_client reconnects and pairs again + ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + return false; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] Read of handle 0x%x failed, status=%d", this->parent_->address_str(), param->read.handle, + param->read.status); + return false; + } + return true; +} + +void Ecocomfort2Hub::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + if (event != ESP_GAP_BLE_AUTH_CMPL_EVT || !this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr) || + this->node_state != espbt::ClientState::ESTABLISHED) { + return; + } + if (!param->ble_security.auth_cmpl.success) { + this->cancel_timeout(AUTH_TIMEOUT_ID); + ESP_LOGW(TAG, "[%s] BLE authentication failed, reason=%d, disconnecting", this->parent_->address_str(), + param->ble_security.auth_cmpl.fail_reason); + this->parent_->disconnect(); + return; + } + this->on_paired_(); +} + +void Ecocomfort2Hub::on_paired_() { + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->status_clear_warning(); + ESP_LOGI(TAG, "[%s] Encryption confirmed, device ready", this->parent_->address_str()); + this->set_connected_(true); +#ifdef USE_TIME + if (this->time_ != nullptr) + this->sync_clock_(); +#endif +#ifdef USE_TEXT_SENSOR + if (this->firmware_text_sensor_ != nullptr && this->char_handle_info_ != 0) + this->read_char_(this->char_handle_info_); +#endif + this->update(); +} + +void Ecocomfort2Hub::set_connected_(bool connected) { +#ifdef USE_BINARY_SENSOR + if (this->connected_binary_sensor_ != nullptr) + this->connected_binary_sensor_->publish_state(connected); +#endif +} + +// Readings from a unit that is out of reach would otherwise look current for hours +void Ecocomfort2Hub::publish_unknown_() { +#ifdef USE_SENSOR + for (auto *sensor : {this->temperature_sensor_, this->humidity_sensor_, this->voc_sensor_, this->direction_sensor_}) { + if (sensor != nullptr) + sensor->publish_state(NAN); + } +#endif +} + +// C_OPER: mode byte, speed/flags byte +void Ecocomfort2Hub::on_oper_(const uint8_t *data, uint16_t len) { + if (len < 2) { + ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), len); + return; + } + this->mode_ = data[0]; + this->speed_ = data[1]; + ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); +#ifdef USE_BINARY_SENSOR + if (this->boost_binary_sensor_ != nullptr) + this->boost_binary_sensor_->publish_state((this->speed_ & FLAG_BOOST) != 0); +#endif +#ifdef ECOCOMFORT2_CHILD_COUNT + for (auto *child : this->children_) { + child->on_status(); + } +#endif +} + +// C_STATE: bytes 3-4 temperature and 5-6 humidity in hundredths, 7-8 VOC in ppb, byte 2 the air flow direction +void Ecocomfort2Hub::on_state_(const uint8_t *data, uint16_t len) { + if (len < 9) { + ESP_LOGW(TAG, "[%s] C_STATE too short: %u bytes", this->parent_->address_str(), len); + return; + } + const float temperature = static_cast(encode_uint16(data[3], data[4])) / 100.0f; + const float humidity = encode_uint16(data[5], data[6]) / 100.0f; + const uint16_t voc = encode_uint16(data[7], data[8]); + ESP_LOGD(TAG, "[%s] C_STATE: %.2f °C, %.2f %%, %u ppb, direction=%u", this->parent_->address_str(), temperature, + humidity, voc, data[2]); +#ifdef USE_SENSOR + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(temperature); + if (this->humidity_sensor_ != nullptr) + this->humidity_sensor_->publish_state(humidity); + if (this->voc_sensor_ != nullptr) + this->voc_sensor_->publish_state(voc); + if (this->direction_sensor_ != nullptr) + this->direction_sensor_->publish_state(data[2]); +#endif +} + +// C_INFO: firmware version packed as 4-bit major, 6-bit minor and 6-bit patch +void Ecocomfort2Hub::on_info_(const uint8_t *data, uint16_t len) { + if (len < 2) { + ESP_LOGW(TAG, "[%s] C_INFO too short: %u bytes", this->parent_->address_str(), len); + return; + } + const uint16_t field = encode_uint16(data[0] & 0x0F, data[1]); + char version[12]; + snprintf(version, sizeof(version), "%u.%u.%u", data[0] >> 4, field >> 6, field & 0x3F); + ESP_LOGD(TAG, "[%s] Firmware %s", this->parent_->address_str(), version); +#ifdef USE_TEXT_SENSOR + if (this->firmware_text_sensor_ != nullptr) + this->firmware_text_sensor_->publish_state(version); +#endif +} + +#ifdef USE_TIME +// C_CLOCK: hour, minute, second, weekday with Monday as 0, day, month, year as two bytes +void Ecocomfort2Hub::sync_clock_() { + if (!this->is_ready() || this->char_handle_clock_ == 0) + return; + const ESPTime now = this->time_->now(); + if (!now.is_valid()) + return; + // ESPTime counts Sunday=1 .. Saturday=7, the unit Monday=0 .. Sunday=6 + const uint8_t weekday = (now.day_of_week + 5) % 7; + uint8_t data[8]{now.hour, + now.minute, + now.second, + weekday, + now.day_of_month, + now.month, + static_cast(now.year >> 8), + static_cast(now.year)}; + ESP_LOGD(TAG, "[%s] Syncing clock to %02u:%02u:%02u", this->parent_->address_str(), now.hour, now.minute, now.second); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_clock_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_CLOCK write failed, status=%d", this->parent_->address_str(), status); + } +} +#endif + +void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { + if (!this->is_ready()) { + ESP_LOGW(TAG, "[%s] Not ready, cannot write C_OPER", this->parent_->address_str()); + return; + } + uint8_t data[2]{mode, speed}; + ESP_LOGD(TAG, "[%s] Writing C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), mode, speed); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_oper_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER write failed, status=%d", this->parent_->address_str(), status); + return; + } + this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->read_char_(this->char_handle_oper_); }); +} + +#ifdef ECOCOMFORT2_CHILD_COUNT +void Ecocomfort2Hub::register_child(Ecocomfort2Client *obj) { + this->children_.push_back(obj); + obj->set_parent(this); +} +#endif + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h new file mode 100644 index 0000000000..ed046fe049 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -0,0 +1,136 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "ecocomfort2_child.h" + +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif +#ifdef USE_TIME +#include "esphome/components/time/real_time_clock.h" +#endif + +namespace esphome::ecocomfort2 { + +namespace espbt = esphome::esp32_ble_tracker; + +// C_OPER byte 0: operating mode +static constexpr uint8_t OPER_OFF = 0x00; +static constexpr uint8_t OPER_IN = 0x01; +static constexpr uint8_t OPER_OUT = 0x02; +static constexpr uint8_t OPER_IN_OUT = 0x03; +static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04; + +// C_OPER byte 1: speed in the low nibble, flags in the high nibble +static constexpr uint8_t SPEED_OFF = 0x00; +static constexpr uint8_t SPEED_SLEEP = 0x01; +static constexpr uint8_t SPEED_VEL1 = 0x02; +static constexpr uint8_t SPEED_VEL2 = 0x03; +static constexpr uint8_t SPEED_VEL3 = 0x04; +static constexpr uint8_t SPEED_MASK = 0x0F; +static constexpr uint8_t FLAG_AUTO = 0x10; +static constexpr uint8_t FLAG_BOOST = 0x40; +static constexpr uint8_t FLAG_NIGHT = 0x80; + +/** Hub component for Ecocomfort 2.0 VMC via BLE. */ +class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { + public: + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BLUETOOTH; } + + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + +#ifdef ECOCOMFORT2_CHILD_COUNT + void register_child(Ecocomfort2Client *obj); +#endif +#ifdef USE_SENSOR + void set_temperature_sensor(sensor::Sensor *s) { this->temperature_sensor_ = s; } + void set_humidity_sensor(sensor::Sensor *s) { this->humidity_sensor_ = s; } + void set_voc_sensor(sensor::Sensor *s) { this->voc_sensor_ = s; } + void set_direction_sensor(sensor::Sensor *s) { this->direction_sensor_ = s; } +#endif +#ifdef USE_BINARY_SENSOR + void set_connected_binary_sensor(binary_sensor::BinarySensor *s) { this->connected_binary_sensor_ = s; } + void set_boost_binary_sensor(binary_sensor::BinarySensor *s) { this->boost_binary_sensor_ = s; } +#endif +#ifdef USE_TEXT_SENSOR + void set_firmware_text_sensor(text_sensor::TextSensor *s) { this->firmware_text_sensor_ = s; } +#endif +#ifdef USE_TIME + /// Keeps the unit's clock in step with this time source while connected. + void set_time(time::RealTimeClock *time) { this->time_ = time; } +#endif + + /// True once the link is established and paired. + bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); } + + /// Write C_OPER (mode byte, speed/flags byte) and read it back shortly after. + void write_oper(uint8_t mode, uint8_t speed); + + /// Raw C_OPER values from the last readback. + uint8_t get_mode() const { return this->mode_; } + uint8_t get_speed() const { return this->speed_; } + + protected: + static constexpr uint32_t AUTH_TIMEOUT_ID = 0; + static constexpr uint32_t READBACK_TIMEOUT_ID = 1; + static constexpr uint32_t AUTH_TIMEOUT_MS = 10000; + static constexpr uint32_t READBACK_DELAY_MS = 300; + + void on_paired_(); + void set_connected_(bool connected); + void publish_unknown_(); + void read_char_(uint16_t handle); + bool read_ok_(const esp_ble_gattc_cb_param_t *param); + void on_oper_(const uint8_t *data, uint16_t len); + void on_state_(const uint8_t *data, uint16_t len); + void on_info_(const uint8_t *data, uint16_t len); +#ifdef USE_TIME + void sync_clock_(); +#endif + +#ifdef ECOCOMFORT2_CHILD_COUNT + StaticVector children_; +#endif +#ifdef USE_SENSOR + sensor::Sensor *temperature_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + sensor::Sensor *voc_sensor_{nullptr}; + sensor::Sensor *direction_sensor_{nullptr}; +#endif +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *connected_binary_sensor_{nullptr}; + binary_sensor::BinarySensor *boost_binary_sensor_{nullptr}; +#endif +#ifdef USE_TEXT_SENSOR + text_sensor::TextSensor *firmware_text_sensor_{nullptr}; +#endif +#ifdef USE_TIME + time::RealTimeClock *time_{nullptr}; +#endif + uint16_t char_handle_oper_{0}; + uint16_t char_handle_state_{0}; + uint16_t char_handle_info_{0}; + uint16_t char_handle_clock_{0}; + uint8_t mode_{OPER_OFF}; + uint8_t speed_{SPEED_OFF}; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/fan.py b/esphome/components/ecocomfort2/fan.py new file mode 100644 index 0000000000..e3dd8e0938 --- /dev/null +++ b/esphome/components/ecocomfort2/fan.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ECOCOMFORT2_CLIENT_SCHEMA, ecocomfort2_ns, register_ecocomfort2_child + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +Ecocomfort2Fan = ecocomfort2_ns.class_("Ecocomfort2Fan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = ( + fan.fan_schema(Ecocomfort2Fan) + .extend(cv.COMPONENT_SCHEMA) + .extend(ECOCOMFORT2_CLIENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = await fan.new_fan(config) + await cg.register_component(var, config) + await register_ecocomfort2_child(var, config) diff --git a/esphome/components/ecocomfort2/sensor.py b/esphome/components/ecocomfort2/sensor.py new file mode 100644 index 0000000000..878cc05f87 --- /dev/null +++ b/esphome/components/ecocomfort2/sensor.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIRECTION, + CONF_HUMIDITY, + CONF_TEMPERATURE, + CONF_VOC, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_PARTS_PER_BILLION, + UNIT_PERCENT, +) +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONFIG_SCHEMA = cv.All( + ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_VOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_DIRECTION): sensor.sensor_schema( + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ), + cv.has_at_least_one_key(CONF_TEMPERATURE, CONF_HUMIDITY, CONF_VOC, CONF_DIRECTION), +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_HUMIDITY, hub.set_humidity_sensor) + await sensors(CONF_VOC, hub.set_voc_sensor) + await sensors(CONF_DIRECTION, hub.set_direction_sensor) diff --git a/esphome/components/ecocomfort2/text_sensor.py b/esphome/components/ecocomfort2/text_sensor.py new file mode 100644 index 0000000000..4f2ac36539 --- /dev/null +++ b/esphome/components/ecocomfort2/text_sensor.py @@ -0,0 +1,24 @@ +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 +from esphome.types import ConfigType + +from . import CONF_ECOCOMFORT2_ID, ECOCOMFORT2_CLIENT_SCHEMA + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +CONFIG_SCHEMA = ECOCOMFORT2_CLIENT_SCHEMA.extend( + { + cv.Required(CONF_VERSION): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + sensors = text_sensor.sub_text_sensors(config) + await sensors(CONF_VERSION, hub.set_firmware_text_sensor) diff --git a/esphome/components/ee895/__init__.py b/esphome/components/ee895/__init__.py index e69de29bb2..82043233c3 100644 --- a/esphome/components/ee895/__init__.py +++ b/esphome/components/ee895/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ee895" diff --git a/esphome/components/ee895/ee895.cpp b/esphome/components/ee895/ee895.cpp index 2dd7405ff5..71ce5b0c70 100644 --- a/esphome/components/ee895/ee895.cpp +++ b/esphome/components/ee895/ee895.cpp @@ -4,7 +4,7 @@ namespace esphome::ee895 { -static const char *const TAG = "ee895"; +ESPHOME_LOG_TAG(TAG, "ee895"); // Serial number is 16 bytes static constexpr size_t EE895_SERIAL_NUMBER_SIZE = 16; 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/ektf2232/__init__.py b/esphome/components/ektf2232/__init__.py index e69de29bb2..03f83e2a58 100644 --- a/esphome/components/ektf2232/__init__.py +++ b/esphome/components/ektf2232/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ektf2232" diff --git a/esphome/components/ektf2232/touchscreen/ektf2232.cpp b/esphome/components/ektf2232/touchscreen/ektf2232.cpp index 51532548c1..81434c9db6 100644 --- a/esphome/components/ektf2232/touchscreen/ektf2232.cpp +++ b/esphome/components/ektf2232/touchscreen/ektf2232.cpp @@ -6,7 +6,7 @@ namespace esphome::ektf2232 { -static const char *const TAG = "ektf2232"; +ESPHOME_LOG_TAG(TAG, "ektf2232"); static const uint8_t SOFT_RESET_CMD[4] = {0x77, 0x77, 0x77, 0x77}; static const uint8_t HELLO[4] = {0x55, 0x55, 0x55, 0x55}; diff --git a/esphome/components/emc2101/__init__.py b/esphome/components/emc2101/__init__.py index 639847345f..024ff3c893 100644 --- a/esphome/components/emc2101/__init__.py +++ b/esphome/components/emc2101/__init__.py @@ -1,10 +1,12 @@ 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 CODEOWNERS = ["@ellull"] +DOMAIN = "emc2101" DEPENDENCIES = ["i2c"] @@ -13,7 +15,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/emc2101.cpp b/esphome/components/emc2101/emc2101.cpp index bb041bfd6d..6a21b4e7b8 100644 --- a/esphome/components/emc2101/emc2101.cpp +++ b/esphome/components/emc2101/emc2101.cpp @@ -7,7 +7,7 @@ namespace esphome::emc2101 { -static const char *const TAG = "EMC2101"; +ESPHOME_LOG_TAG(TAG, "EMC2101"); static const uint8_t EMC2101_CHIP_ID = 0x16; // EMC2101 default device id from part id static const uint8_t EMC2101_ALT_CHIP_ID = 0x28; // EMC2101 alternate device id from part 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/emc2101/sensor/emc2101_sensor.cpp b/esphome/components/emc2101/sensor/emc2101_sensor.cpp index bb5eea21f3..bcc8c01c4f 100644 --- a/esphome/components/emc2101/sensor/emc2101_sensor.cpp +++ b/esphome/components/emc2101/sensor/emc2101_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::emc2101 { -static const char *const TAG = "EMC2101.sensor"; +ESPHOME_LOG_TAG(TAG, "EMC2101.sensor"); void EMC2101Sensor::dump_config() { ESP_LOGCONFIG(TAG, "Emc2101 sensor:"); diff --git a/esphome/components/emmeti/__init__.py b/esphome/components/emmeti/__init__.py index e69de29bb2..ec6d2835b0 100644 --- a/esphome/components/emmeti/__init__.py +++ b/esphome/components/emmeti/__init__.py @@ -0,0 +1 @@ +DOMAIN = "emmeti" diff --git a/esphome/components/emmeti/emmeti.cpp b/esphome/components/emmeti/emmeti.cpp index 82991aeac7..5cf0fd8ae1 100644 --- a/esphome/components/emmeti/emmeti.cpp +++ b/esphome/components/emmeti/emmeti.cpp @@ -3,7 +3,7 @@ namespace esphome::emmeti { -static const char *const TAG = "emmeti.climate"; +ESPHOME_LOG_TAG(TAG, "emmeti.climate"); // setters uint8_t EmmetiClimate::set_temp_() { diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 3821f3e10e..d6c46b5509 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -3,6 +3,8 @@ from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg from esphome.components import uart +from esphome.components.sensor import DOMAIN as SENSOR_DOMAIN +from esphome.components.uart import DOMAIN as UART_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_COMMAND, @@ -11,8 +13,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,12 +24,10 @@ 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" +CONF_LOCK_ON_REBOOT = "lock_on_reboot" DOMAIN = "emontx" @@ -46,13 +45,21 @@ def _get_data() -> EmonTxData: return CORE.data[DOMAIN] +def _synchronous_automation_schema() -> cv.All: + # The received line and the parsed JSON document only live while the callbacks run. + return automation.validate_synchronous_automation( + "on_json and on_data", "the received data" + ) + + # Main configuration schema CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(EmonTx), - cv.Optional(CONF_ON_JSON): automation.validate_automation({}), - cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + cv.Optional(CONF_ON_JSON): _synchronous_automation_schema(), + cv.Optional(CONF_ON_DATA): _synchronous_automation_schema(), + cv.Optional(CONF_LOCK_ON_REBOOT, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) @@ -67,13 +74,13 @@ def final_validate(config: ConfigType) -> None: hub_id = str(config[CONF_ID]) sensor_count = sum( 1 - for s in full_config.get("sensor", []) - if s.get("platform") == "emontx" and str(s.get(CONF_EMONTX_ID)) == hub_id + for s in full_config.get(SENSOR_DOMAIN, []) + if s.get("platform") == DOMAIN and str(s.get(CONF_EMONTX_ID)) == hub_id ) _get_data().sensor_counts[hub_id] = sensor_count # Ensure UART RX buffer size is large enough to handle data bursts from firmware - for uart_conf in full_config["uart"]: + for uart_conf in full_config[UART_DOMAIN]: if uart_conf[CONF_ID] == config[CONF_UART_ID]: current_buffer_size = uart_conf[CONF_RX_BUFFER_SIZE] if current_buffer_size < MINIMUM_RX_BUFFER_SIZE: @@ -106,10 +113,10 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_JSON, "add_on_json_callback", - [(cg.JsonObject, "json"), (cg.std_string, "raw_json")], + [(cg.JsonObject, "json"), (cg.StringRef, "raw_json")], ), automation.CallbackAutomation( - CONF_ON_DATA, "add_on_data_callback", [(cg.std_string, "data")] + CONF_ON_DATA, "add_on_data_callback", [(cg.StringRef, "data")] ), ) @@ -128,6 +135,9 @@ async def to_code(config: ConfigType) -> None: await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + if config[CONF_LOCK_ON_REBOOT]: + cg.add(var.set_lock_on_reboot(True)) + # Action: emontx.send_command @@ -139,20 +149,29 @@ 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 + + +# Let another component (e.g. serial_proxy) take exclusive ownership of the UART. + +EMONTX_ID_SCHEMA = automation.maybe_simple_id({cv.GenerateID(): cv.use_id(EmonTx)}) + +automation.register_apply_action( + "emontx.pause", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(true)") +) +automation.register_apply_action( + "emontx.resume", EMONTX_ID_SCHEMA, automation.ApplyCall("set_paused(false)") +) +automation.register_apply_condition("emontx.is_paused", EMONTX_ID_SCHEMA, "is_paused()") diff --git a/esphome/components/emontx/emontx.cpp b/esphome/components/emontx/emontx.cpp index 7a1b084fe0..14dc89e56a 100644 --- a/esphome/components/emontx/emontx.cpp +++ b/esphome/components/emontx/emontx.cpp @@ -4,9 +4,29 @@ namespace esphome::emontx { -static const char *const TAG = "emontx"; +ESPHOME_LOG_TAG(TAG, "emontx"); -void EmonTx::setup() { this->buffer_pos_ = 0; } +void EmonTx::setup() { + this->buffer_pos_ = 0; + if (!this->lock_on_reboot_) + return; + // The line feed ends any boot noise first + ESP_LOGI(TAG, "Unlocking emonTx after boot"); + this->write_byte('\n'); + this->send_command("emonunlock"); +} + +void EmonTx::on_shutdown() { + if (!this->lock_on_reboot_) + return; + if (this->is_paused()) { + ESP_LOGW(TAG, "Not locking emonTx before shutdown, UART is paused"); + return; + } + ESP_LOGI(TAG, "Locking emonTx before shutdown"); + this->send_command("emonlock"); + this->flush(); +} /** * @brief Implements the main loop for parsing data from the serial port. @@ -16,15 +36,31 @@ void EmonTx::setup() { this->buffer_pos_ = 0; } * 2. If line starts with '{', parse as JSON and update sensors/callbacks */ void EmonTx::loop() { + if (this->is_paused()) { + this->was_paused_ = true; + return; + } + if (this->was_paused_) { + // Drop what arrived during the pause and resync at the next line start + this->was_paused_ = false; + this->buffer_pos_ = 0; + this->discarding_line_ = true; + uint8_t dropped; + while (this->available() > 0 && this->read_byte(&dropped)) + this->discarding_line_ = dropped != '\n'; + } + // Read all available data to prevent UART buffer overflow while (this->available() > 0) { - uint8_t received = this->read(); + uint8_t received; + if (!this->read_byte(&received)) + break; - if (received == '\r') { - continue; // Ignore CR - } else if (received == '\n') { + if (received == '\n') { // End of line - process the buffer - if (this->buffer_pos_ > 0) { + if (this->discarding_line_) { + this->discarding_line_ = false; + } else if (this->buffer_pos_ > 0) { // Null-terminate for safe logging and c_str() use size_t len = this->buffer_pos_; this->buffer_[len] = '\0'; @@ -42,9 +78,12 @@ void EmonTx::loop() { this->parse_json_(this->buffer_.data(), len); } } + } else if (received == '\r' || this->discarding_line_) { + // Ignore CR, and the rest of a line that overflowed or was interrupted by a pause } else if (this->buffer_pos_ >= MAX_LINE_LENGTH) { - ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding buffer", MAX_LINE_LENGTH); + ESP_LOGW(TAG, "Buffer overflow (>%zu bytes), discarding line", MAX_LINE_LENGTH); this->buffer_pos_ = 0; + this->discarding_line_ = true; } else { this->buffer_[this->buffer_pos_++] = static_cast(received); } @@ -78,6 +117,7 @@ void EmonTx::parse_json_(const char *data, size_t len) { */ void EmonTx::dump_config() { ESP_LOGCONFIG(TAG, "EmonTx:"); + ESP_LOGCONFIG(TAG, " Lock on reboot: %s", YESNO(this->lock_on_reboot_)); #ifdef USE_SENSOR ESP_LOGCONFIG(TAG, " Registered sensors: %zu", this->sensors_.size()); @@ -94,9 +134,13 @@ 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) { + if (this->is_paused()) { + ESP_LOGW(TAG, "Not sending command, UART is paused: %s", command); + return; + } + 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..bcec7ce171 100644 --- a/esphome/components/emontx/emontx.h +++ b/esphome/components/emontx/emontx.h @@ -2,13 +2,13 @@ #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" #include "esphome/components/json/json_util.h" #include +#include #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" @@ -33,13 +33,25 @@ class EmonTx final : public Component, public uart::UARTDevice { void loop() override; void setup() override; void dump_config() override; + void on_shutdown() override; template void add_on_json_callback(F &&callback) { this->json_callbacks_.add(std::forward(callback)); } 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()); } + + /// Stop reading the UART and sending commands, so another component can use it. + /// Atomic, so it can be called from any task. Hold the pause for at least one loop() pass. + void set_paused(bool paused) { this->paused_.store(paused, std::memory_order_relaxed); } + + bool is_paused() const { return this->paused_.load(std::memory_order_relaxed); } + + /// Lock the emonTx ('emonlock') before a reboot, so serial garbage during the reboot is not taken + /// as a command, and unlock it in setup(). Needs emonTx firmware v1.2.0 or later. + void set_lock_on_reboot(bool lock_on_reboot) { this->lock_on_reboot_ = lock_on_reboot; } #ifdef USE_SENSOR void init_sensors(size_t count) { this->sensors_.init(count); } @@ -55,15 +67,11 @@ class EmonTx final : public Component, public uart::UARTDevice { LazyCallbackManager json_callbacks_; LazyCallbackManager data_callbacks_; uint16_t buffer_pos_{0}; + std::atomic paused_{false}; + bool was_paused_{false}; + bool lock_on_reboot_{false}; + bool discarding_line_{false}; // Set after an overflow or a pause until the end of that line 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/emontx/sensor/emontx_sensor.cpp b/esphome/components/emontx/sensor/emontx_sensor.cpp index 142df0150e..d6f3200e99 100644 --- a/esphome/components/emontx/sensor/emontx_sensor.cpp +++ b/esphome/components/emontx/sensor/emontx_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::emontx { -static const char *const TAG = "emontx_sensor"; +ESPHOME_LOG_TAG(TAG, "emontx_sensor"); void EmonTxSensor::dump_config() { LOG_SENSOR(" ", "EmonTx Sensor", this); } diff --git a/esphome/components/endstop/__init__.py b/esphome/components/endstop/__init__.py index e69de29bb2..c064448409 100644 --- a/esphome/components/endstop/__init__.py +++ b/esphome/components/endstop/__init__.py @@ -0,0 +1 @@ +DOMAIN = "endstop" diff --git a/esphome/components/endstop/endstop_cover.cpp b/esphome/components/endstop/endstop_cover.cpp index b3c210a8d8..25b5b54209 100644 --- a/esphome/components/endstop/endstop_cover.cpp +++ b/esphome/components/endstop/endstop_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::endstop { -static const char *const TAG = "endstop.cover"; +ESPHOME_LOG_TAG(TAG, "endstop.cover"); using namespace esphome::cover; diff --git a/esphome/components/ens160/__init__.py b/esphome/components/ens160/__init__.py index e69de29bb2..e59458427d 100644 --- a/esphome/components/ens160/__init__.py +++ b/esphome/components/ens160/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ens160" diff --git a/esphome/components/ens160_base/__init__.py b/esphome/components/ens160_base/__init__.py index 1bdfb0c0a6..8ef5971f17 100644 --- a/esphome/components/ens160_base/__init__.py +++ b/esphome/components/ens160_base/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor +from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( CONF_COMPENSATION, @@ -22,10 +23,10 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@vincentscode", "@latonita"] +DOMAIN = "ens160_base" ens160_ns = cg.esphome_ns.namespace("ens160_base") -CONF_AQI = "aqi" CONFIG_SCHEMA_BASE = cv.Schema( { @@ -63,15 +64,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/ens160_base/ens160_base.cpp b/esphome/components/ens160_base/ens160_base.cpp index 20c67c3450..b06182e59c 100644 --- a/esphome/components/ens160_base/ens160_base.cpp +++ b/esphome/components/ens160_base/ens160_base.cpp @@ -20,7 +20,7 @@ namespace esphome::ens160_base { -static const char *const TAG = "ens160"; +ESPHOME_LOG_TAG(TAG, "ens160"); // Datasheet specifies 10ms, but some users report that 10ms is not sufficient for the // sensor to boot and be ready for commands. 11ms seems to be a safe value. diff --git a/esphome/components/ens160_i2c/__init__.py b/esphome/components/ens160_i2c/__init__.py index e69de29bb2..605acf0f53 100644 --- a/esphome/components/ens160_i2c/__init__.py +++ b/esphome/components/ens160_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ens160_i2c" diff --git a/esphome/components/ens160_i2c/ens160_i2c.cpp b/esphome/components/ens160_i2c/ens160_i2c.cpp index 1f02ddb718..b65855b373 100644 --- a/esphome/components/ens160_i2c/ens160_i2c.cpp +++ b/esphome/components/ens160_i2c/ens160_i2c.cpp @@ -7,7 +7,7 @@ namespace esphome::ens160_i2c { -static const char *const TAG = "ens160_i2c.sensor"; +ESPHOME_LOG_TAG(TAG, "ens160_i2c.sensor"); bool ENS160I2CComponent::read_byte(uint8_t a_register, uint8_t *data) { return I2CDevice::read_byte(a_register, data); diff --git a/esphome/components/ens160_spi/__init__.py b/esphome/components/ens160_spi/__init__.py index e69de29bb2..88ad91e632 100644 --- a/esphome/components/ens160_spi/__init__.py +++ b/esphome/components/ens160_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ens160_spi" diff --git a/esphome/components/ens160_spi/ens160_spi.cpp b/esphome/components/ens160_spi/ens160_spi.cpp index 41ab8298db..fe46c22c60 100644 --- a/esphome/components/ens160_spi/ens160_spi.cpp +++ b/esphome/components/ens160_spi/ens160_spi.cpp @@ -6,7 +6,7 @@ namespace esphome::ens160_spi { -static const char *const TAG = "ens160_spi.sensor"; +ESPHOME_LOG_TAG(TAG, "ens160_spi.sensor"); inline uint8_t reg_read(uint8_t reg) { return (reg << 1) | 0x01; } diff --git a/esphome/components/ens210/__init__.py b/esphome/components/ens210/__init__.py index e69de29bb2..58f5d4f25f 100644 --- a/esphome/components/ens210/__init__.py +++ b/esphome/components/ens210/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ens210" diff --git a/esphome/components/ens210/ens210.cpp b/esphome/components/ens210/ens210.cpp index 11b73afe37..5815fc9038 100644 --- a/esphome/components/ens210/ens210.cpp +++ b/esphome/components/ens210/ens210.cpp @@ -12,7 +12,9 @@ namespace esphome::ens210 { -static const char *const TAG = "ens210"; +ESPHOME_LOG_TAG(TAG, "ens210"); + +static constexpr uint32_t DATA_TIMEOUT_ID = 0; // ENS210 chip constants static const uint8_t ENS210_BOOTING_MS = 2; // Booting time in ms (also after reset, or going to high power) @@ -149,7 +151,7 @@ void ENS210Component::update() { return; } // Wait for measurement to complete - this->set_timeout("data", uint32_t(ENS210_SINGLE_MEASURMENT_CONVERSION_TIME_MS), [this]() { + this->set_timeout(DATA_TIMEOUT_ID, uint32_t(ENS210_SINGLE_MEASURMENT_CONVERSION_TIME_MS), [this]() { int temperature_data, temperature_status, humidity_data, humidity_status; uint8_t data[6]; uint32_t h_val_data, t_val_data; 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/__init__.py b/esphome/components/epaper_spi/__init__.py index f70ffa9520..da3685c263 100644 --- a/esphome/components/epaper_spi/__init__.py +++ b/esphome/components/epaper_spi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "epaper_spi" 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 3b3418d911..0c1d9b0faf 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { -static const char *const TAG = "epaper_spi"; +ESPHOME_LOG_TAG(TAG, "epaper_spi"); static constexpr size_t EPAPER_MAX_CMD_LOG_BYTES = 128; static constexpr const char *const EPAPER_STATE_STRINGS[] = { @@ -196,6 +196,15 @@ void EPaperBase::process_state_() { break; case EPaperState::UPDATE: this->do_update_(); // Calls ESPHome (current page) lambda + if (this->full_update_requested_) { + // Refresh the whole panel even if nothing was drawn + this->full_update_requested_ = false; + this->update_count_ = 0; + this->x_low_ = 0; + this->y_low_ = 0; + this->x_high_ = this->width_; + this->y_high_ = this->height_; + } if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) { this->set_state_(EPaperState::IDLE); return; @@ -327,9 +336,9 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { return; const size_t byte_position = y * this->row_width_ + x / 8; const uint8_t bit_position = x % 8; - const uint8_t pixel_bit = 0x80 >> bit_position; + const uint8_t pixel_bit = 0x80u >> bit_position; const auto original = this->buffer_[byte_position]; - if ((color_to_bit(color) == 0)) { + if (color_to_mono(color) == 0) { this->buffer_[byte_position] = original & ~pixel_bit; } else { this->buffer_[byte_position] = original | pixel_bit; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 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_4bpp.cpp b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp index 820dc3c77a..0f7f7549d8 100644 --- a/esphome/components/epaper_spi/epaper_spi_4bpp.cpp +++ b/esphome/components/epaper_spi/epaper_spi_4bpp.cpp @@ -4,7 +4,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.4bpp"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.4bpp"); void EPaper4bpp::fill(Color color) { // If clipping is active, fall back to base implementation diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp index fc0d674246..60e3245def 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp +++ b/esphome/components/epaper_spi/epaper_spi_inkplate2.cpp @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.inkplate2"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.inkplate2"); // Map RGB to the panel's black/white/red via the shared converter. enum class Inkplate2Color : uint8_t { BLACK, WHITE, RED }; diff --git a/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp index b81542b6c1..5bffba35c9 100644 --- a/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp +++ b/esphome/components/epaper_spi/epaper_spi_inkplate6color.cpp @@ -7,7 +7,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.inkplate6color"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.inkplate6color"); // Native hardware color codes for this panel's 4-bit color values. enum Inkplate6ColorHex : uint8_t { diff --git a/esphome/components/epaper_spi/epaper_spi_jd79660.cpp b/esphome/components/epaper_spi/epaper_spi_jd79660.cpp index 1cd1087c6b..0de044e8d7 100644 --- a/esphome/components/epaper_spi/epaper_spi_jd79660.cpp +++ b/esphome/components/epaper_spi/epaper_spi_jd79660.cpp @@ -4,7 +4,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.jd79660"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.jd79660"); /** Pixel color as 2bpp. Must match IC LUT values. */ enum JD79660Color : uint8_t { diff --git a/esphome/components/epaper_spi/epaper_spi_mono.cpp b/esphome/components/epaper_spi/epaper_spi_mono.cpp index fffb2b5e84..b6653038c3 100644 --- a/esphome/components/epaper_spi/epaper_spi_mono.cpp +++ b/esphome/components/epaper_spi/epaper_spi_mono.cpp @@ -5,7 +5,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.mono"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.mono"); void EPaperMono::refresh_screen(bool partial) { ESP_LOGV(TAG, "Refresh screen"); 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_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index f47d37d550..a2fe9bd641 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -4,7 +4,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.6c"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.6c"); // Native hardware color codes for this panel's 4-bit color values. enum E6Color : uint8_t { 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..a9245fa1c2 --- /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 { +ESPHOME_LOG_TAG(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..34120c908b --- /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 { + +ESPHOME_LOG_TAG(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/epaper_spi_ssd1683.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp index 6fb7e1ac1a..9b6acf412a 100644 --- a/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp +++ b/esphome/components/epaper_spi/epaper_spi_ssd1683.cpp @@ -5,7 +5,7 @@ #include "esphome/core/log.h" namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.mono"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.mono"); void EPaperSSD1683::refresh_screen(bool partial) { ESP_LOGV(TAG, "Refresh screen"); diff --git a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp index 95d1fcb484..bd5a4c9b6e 100644 --- a/esphome/components/epaper_spi/epaper_spi_t133a01.cpp +++ b/esphome/components/epaper_spi/epaper_spi_t133a01.cpp @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.t133a01"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.t133a01"); // Color indices used in the 4bpp buffer (sprite-side) // These MUST match the Arduino GFX TFT_eSPI.h color definitions and diff --git a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp index 2a4ff2969a..328cd83329 100644 --- a/esphome/components/epaper_spi/epaper_spi_uc8179.cpp +++ b/esphome/components/epaper_spi/epaper_spi_uc8179.cpp @@ -6,7 +6,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_spi.uc8179"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.uc8179"); bool EPaperUC8179::initialise(bool partial) { EPaperBase::initialise(partial); // send the model init sequence diff --git a/esphome/components/epaper_spi/epaper_waveshare.cpp b/esphome/components/epaper_spi/epaper_waveshare.cpp index 7a7b4e22d3..d4c7ba5fdb 100644 --- a/esphome/components/epaper_spi/epaper_waveshare.cpp +++ b/esphome/components/epaper_spi/epaper_waveshare.cpp @@ -2,7 +2,7 @@ namespace esphome::epaper_spi { -static const char *const TAG = "epaper_spi.waveshare"; +ESPHOME_LOG_TAG(TAG, "epaper_spi.waveshare"); bool EpaperWaveshare::initialise(bool partial) { EPaperBase::initialise(partial); diff --git a/esphome/components/epaper_spi/epaper_weact_3c.cpp b/esphome/components/epaper_spi/epaper_weact_3c.cpp index ad2021ed64..90cf6af7b2 100644 --- a/esphome/components/epaper_spi/epaper_weact_3c.cpp +++ b/esphome/components/epaper_spi/epaper_weact_3c.cpp @@ -3,7 +3,7 @@ namespace esphome::epaper_spi { -static constexpr const char *const TAG = "epaper_weact_3c"; +ESPHOME_LOG_TAG(TAG, "epaper_weact_3c"); enum class BwrState : uint8_t { BWR_BLACK, 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..a70d3fe522 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: @@ -116,3 +118,39 @@ jd79660.extend( (0x04,), ), ) + +# Good Display GDEY0213F51 +# +# Panel: 2.13" BWYR (4-color) e-paper, 122x250, controller JD79661 +# Init derived from Good Display sample +# +# Cross-checked against GxEPD2_213c_GDEY0213F51 +# +# +# Note: busy pin uses LOW=busy, HIGH=idle. +# +# fmt: off +jd79660.extend( + "goodisplay-gdey0213f51-2.13", + width=128, + height=250, + + initsequence=( + (0x4D, 0x78,), + (0x00, 0x0F, 0x29,), # PSR + (0x01, 0x07, 0x00,), # PWRR + (0x03, 0x10, 0x54, 0x44,), # POFS + (0x06, 0x05, 0x00, 0x3F, 0x0A, 0x25, 0x12, 0x1A,), # BTST_P + (0x50, 0x37,), # CDI + (0x60, 0x02, 0x02,), # TCON + (0x61, 128 // 256, 128 % 256, 250 // 256, 250 % 256,), # TRES: 128x250 fixed + (0xE7, 0x1C,), + (0xE3, 0x22,), + (0xB4, 0xD0,), + (0xB5, 0x03,), + (0xE9, 0x01,), + (0x30, 0x08,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), +) 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/__init__.py b/esphome/components/es7210/__init__.py index e69de29bb2..93bb826919 100644 --- a/esphome/components/es7210/__init__.py +++ b/esphome/components/es7210/__init__.py @@ -0,0 +1,3 @@ +"""ES7210 audio ADC component.""" + +DOMAIN = "es7210" diff --git a/esphome/components/es7210/audio_adc.py b/esphome/components/es7210/audio_adc.py index 2defdb0c35..c2dd40b3e8 100644 --- a/esphome/components/es7210/audio_adc.py +++ b/esphome/components/es7210/audio_adc.py @@ -1,13 +1,18 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import esp32, i2c from esphome.components.audio_adc import AudioAdc +from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32S2 import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_ID, CONF_MIC_GAIN, CONF_SAMPLE_RATE +from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["i2c"] +CONF_CHANNEL_GAINS = "channel_gains" +CONF_TDM = "tdm" + es7210_ns = cg.esphome_ns.namespace("es7210") ES7210 = es7210_ns.class_("ES7210", AudioAdc, cg.Component, i2c.I2CDevice) @@ -21,24 +26,42 @@ ES7210_BITS_PER_SAMPLE_ENUM = { ES7210_MIC_GAINS = [0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 34.5, 36, 37.5] +ES7210_MIC_GAIN_DEFAULT = 24 _validate_bits = cv.float_with_unit("bits", "bit") -CONFIG_SCHEMA = ( + +def _validate_tdm(value: bool) -> bool: + """Reject TDM on ESP32 variants without TDM hardware support.""" + if value and CORE.is_esp32: + esp32.only_on_variant( + unsupported=[VARIANT_ESP32, VARIANT_ESP32S2], + msg_prefix="ES7210 TDM mode", + )(value) + return value + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(ES7210), cv.Optional(CONF_BITS_PER_SAMPLE, default="16bit"): cv.All( _validate_bits, cv.enum(ES7210_BITS_PER_SAMPLE_ENUM) ), - cv.Optional(CONF_MIC_GAIN, default="24db"): cv.All( + cv.Optional(CONF_MIC_GAIN): cv.All( cv.decibel, cv.one_of(*ES7210_MIC_GAINS) ), + cv.Optional(CONF_CHANNEL_GAINS): cv.All( + cv.ensure_list(cv.All(cv.decibel, cv.one_of(*ES7210_MIC_GAINS))), + cv.Length(min=4, max=4), + ), cv.Optional(CONF_SAMPLE_RATE, default=16000): cv.int_range(min=1), + cv.Optional(CONF_TDM, default=False): cv.All(cv.boolean, _validate_tdm), } ) .extend(cv.COMPONENT_SCHEMA) - .extend(i2c.i2c_device_schema(0x40)) + .extend(i2c.i2c_device_schema(0x40)), + cv.has_at_most_one_key(CONF_MIC_GAIN, CONF_CHANNEL_GAINS), ) @@ -48,5 +71,10 @@ async def to_code(config: ConfigType) -> None: await i2c.register_i2c_device(var, config) cg.add(var.set_bits_per_sample(config[CONF_BITS_PER_SAMPLE])) - cg.add(var.set_mic_gain(config[CONF_MIC_GAIN])) + if channel_gains := config.get(CONF_CHANNEL_GAINS): + for channel, gain in enumerate(channel_gains): + cg.add(var.set_channel_gain(channel, gain)) + else: + cg.add(var.set_mic_gain(config.get(CONF_MIC_GAIN, ES7210_MIC_GAIN_DEFAULT))) cg.add(var.set_sample_rate(config[CONF_SAMPLE_RATE])) + cg.add(var.set_tdm(config[CONF_TDM])) diff --git a/esphome/components/es7210/es7210.cpp b/esphome/components/es7210/es7210.cpp index 5afc22aec4..fbabf7460f 100644 --- a/esphome/components/es7210/es7210.cpp +++ b/esphome/components/es7210/es7210.cpp @@ -6,7 +6,7 @@ namespace esphome::es7210 { -static const char *const TAG = "es7210"; +ESPHOME_LOG_TAG(TAG, "es7210"); static const size_t MCLK_DIV_FRE = 256; @@ -27,8 +27,10 @@ void ES7210::dump_config() { ESP_LOGCONFIG(TAG, "ES7210 audio ADC:\n" " Bits Per Sample: %" PRIu8 "\n" - " Sample Rate: %" PRIu32, - this->bits_per_sample_, this->sample_rate_); + " Sample Rate: %" PRIu32 "\n" + " Channel Gains: %.1f dB, %.1f dB, %.1f dB, %.1f dB", + this->bits_per_sample_, this->sample_rate_, this->channel_gains_[0], this->channel_gains_[1], + this->channel_gains_[2], this->channel_gains_[3]); if (this->is_failed()) { ESP_LOGE(TAG, " Failed to initialize"); @@ -88,7 +90,17 @@ void ES7210::setup() { } bool ES7210::set_mic_gain(float mic_gain) { - this->mic_gain_ = clamp(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX); + this->channel_gains_.fill(clamp(mic_gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX)); + if (this->setup_complete_) { + return this->configure_mic_gain_(); + } + return true; +} + +bool ES7210::set_channel_gain(uint8_t channel, float gain) { + if (channel >= this->channel_gains_.size()) + return false; + this->channel_gains_[channel] = clamp(gain, ES7210_MIC_GAIN_MIN, ES7210_MIC_GAIN_MAX); if (this->setup_complete_) { return this->configure_mic_gain_(); } @@ -133,7 +145,6 @@ bool ES7210::configure_sample_rate_() { } bool ES7210::configure_mic_gain_() { - auto regv = this->es7210_gain_reg_value_(this->mic_gain_); for (uint8_t i = 0; i < 4; ++i) { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43 + i, 0x10, 0x00)); } @@ -144,26 +155,30 @@ bool ES7210::configure_mic_gain_() { ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC12_POWER_REG4B, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC1_GAIN_REG43, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[0]))); // Configure mic 2 ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00)); ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC12_POWER_REG4B, 0x00)); ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x10, 0x10)); - ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[1]))); // Configure mic 3 // 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)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[2]))); // Configure mic 4 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)); + ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, + this->es7210_gain_reg_value_(this->channel_gains_[3]))); return true; } diff --git a/esphome/components/es7210/es7210.h b/esphome/components/es7210/es7210.h index 42c667b658..7278d5b3c4 100644 --- a/esphome/components/es7210/es7210.h +++ b/esphome/components/es7210/es7210.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "esphome/components/audio_adc/audio_adc.h" #include "esphome/components/i2c/i2c.h" #include "esphome/core/component.h" @@ -27,10 +29,15 @@ class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I void dump_config() override; void set_bits_per_sample(ES7210BitsPerSample bits_per_sample) { this->bits_per_sample_ = bits_per_sample; } + /// Sets all four channel gains, replacing any configured per-channel gains. bool set_mic_gain(float mic_gain) override; + /// Sets a zero-based channel gain; returns false for an invalid channel or an I2C failure. + bool set_channel_gain(uint8_t channel, float gain); void set_sample_rate(uint32_t sample_rate) { this->sample_rate_ = sample_rate; } + void set_tdm(bool enable_tdm) { this->enable_tdm_ = enable_tdm; } - float mic_gain() override { return this->mic_gain_; }; + /// Returns the MIC1 gain; other channels may have different gains. + float mic_gain() override { return this->channel_gains_[0]; }; protected: /// @brief Updates an I2C registry address by modifying the current state @@ -50,8 +57,8 @@ class ES7210 final : public audio_adc::AudioAdc, public Component, public i2c::I bool configure_sample_rate_(); bool setup_complete_{false}; - bool enable_tdm_{false}; // TDM is unsupported in ESPHome as of version 2024.12 - float mic_gain_{0}; + bool enable_tdm_{false}; + std::array channel_gains_{{0.0f, 0.0f, 0.0f, 0.0f}}; ES7210BitsPerSample bits_per_sample_{ES7210_BITS_PER_SAMPLE_16}; uint32_t sample_rate_{0}; }; diff --git a/esphome/components/es7243e/__init__.py b/esphome/components/es7243e/__init__.py index e69de29bb2..6968b84c93 100644 --- a/esphome/components/es7243e/__init__.py +++ b/esphome/components/es7243e/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es7243e" diff --git a/esphome/components/es7243e/es7243e.cpp b/esphome/components/es7243e/es7243e.cpp index fc3cba7ae4..1bfa6933dd 100644 --- a/esphome/components/es7243e/es7243e.cpp +++ b/esphome/components/es7243e/es7243e.cpp @@ -8,7 +8,7 @@ namespace esphome::es7243e { -static const char *const TAG = "es7243e"; +ESPHOME_LOG_TAG(TAG, "es7243e"); // Mark the component as failed; use only in setup #define ES7243E_ERROR_FAILED(func) \ diff --git a/esphome/components/es8156/__init__.py b/esphome/components/es8156/__init__.py index e69de29bb2..f1e7f4d4e7 100644 --- a/esphome/components/es8156/__init__.py +++ b/esphome/components/es8156/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es8156" diff --git a/esphome/components/es8156/audio_dac.py b/esphome/components/es8156/audio_dac.py index 305aa92125..ebba75e70d 100644 --- a/esphome/components/es8156/audio_dac.py +++ b/esphome/components/es8156/audio_dac.py @@ -1,6 +1,7 @@ import esphome.codegen as cg from esphome.components import i2c from esphome.components.audio_dac import AudioDac +from esphome.components.speaker import DOMAIN as SPEAKER_DOMAIN import esphome.config_validation as cv from esphome.const import CONF_AUDIO_DAC, CONF_BITS_PER_SAMPLE, CONF_ID import esphome.final_validate as fv @@ -27,7 +28,7 @@ def _final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() # Check all speaker configurations for ones that reference this es8156 - speaker_configs = full_config.get("speaker", []) + speaker_configs = full_config.get(SPEAKER_DOMAIN, []) for speaker_config in speaker_configs: audio_dac_id = speaker_config.get(CONF_AUDIO_DAC) if ( diff --git a/esphome/components/es8156/es8156.cpp b/esphome/components/es8156/es8156.cpp index 03d9713df9..467402e704 100644 --- a/esphome/components/es8156/es8156.cpp +++ b/esphome/components/es8156/es8156.cpp @@ -6,7 +6,7 @@ namespace esphome::es8156 { -static const char *const TAG = "es8156"; +ESPHOME_LOG_TAG(TAG, "es8156"); // Mark the component as failed; use only in setup #define ES8156_ERROR_FAILED(func) \ diff --git a/esphome/components/es8311/__init__.py b/esphome/components/es8311/__init__.py index e69de29bb2..975291c4a7 100644 --- a/esphome/components/es8311/__init__.py +++ b/esphome/components/es8311/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es8311" diff --git a/esphome/components/es8311/es8311.cpp b/esphome/components/es8311/es8311.cpp index 0386d84200..96b644ee78 100644 --- a/esphome/components/es8311/es8311.cpp +++ b/esphome/components/es8311/es8311.cpp @@ -6,7 +6,7 @@ namespace esphome::es8311 { -static const char *const TAG = "es8311"; +ESPHOME_LOG_TAG(TAG, "es8311"); // Mark the component as failed; use only in setup #define ES8311_ERROR_FAILED(func) \ @@ -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/__init__.py b/esphome/components/es8388/__init__.py index e69de29bb2..e8e2e361ba 100644 --- a/esphome/components/es8388/__init__.py +++ b/esphome/components/es8388/__init__.py @@ -0,0 +1 @@ +DOMAIN = "es8388" diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 0b97240230..b57949ccd0 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -6,7 +6,7 @@ namespace esphome::es8388 { -static const char *const TAG = "es8388"; +ESPHOME_LOG_TAG(TAG, "es8388"); // Mark the component as failed; use only in setup #define ES8388_ERROR_FAILED(func) \ @@ -231,7 +231,7 @@ bool ES8388::set_dac_output(DacOutputLine line) { optional ES8388::get_dac_power() { uint8_t dac_power; if (!this->read_byte(ES8388_DACPOWER, &dac_power)) { - this->status_momentary_warning("dacpower_read"); + this->status_momentary_warning(); return {}; } switch (dac_power) { @@ -274,7 +274,7 @@ bool ES8388::set_adc_input_mic(AdcInputMicLine line) { optional ES8388::get_mic_input() { uint8_t mic_input; if (!this->read_byte(ES8388_ADCCONTROL2, &mic_input)) { - this->status_momentary_warning("adccontrol2_read"); + this->status_momentary_warning(); return {}; } switch (mic_input) { 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 0d409b7857..422413a269 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,8 +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.config_helpers import filter_source_files_from_defines +from esphome.components.const import ( + CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, + CONF_IGNORE_NOT_FOUND, +) import esphome.config_validation as cv from esphome.const import ( CONF_ADVANCED, @@ -54,7 +56,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 +75,7 @@ from .const import ( KEY_FLASH_SIZE, KEY_FULL_CERT_BUNDLE, KEY_IDF_VERSION, + KEY_MBEDTLS_SDKCONFIG, KEY_NETWORK_SDKCONFIG, KEY_PATH, KEY_REF, @@ -102,6 +105,7 @@ from .gpio import esp32_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] CODEOWNERS = ["@esphome/core"] +DOMAIN = "esp32" IS_TARGET_PLATFORM = True CONF_ASSERTION_LEVEL = "assertion_level" @@ -111,6 +115,7 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample" CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components" CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert" CONF_EXECUTE_FROM_PSRAM = "execute_from_psram" +CONF_NVS_CACHE_IN_PSRAM = "nvs_cache_in_psram" CONF_FLASH_CHIP = "flash_chip" CONF_KEY_ID = "key_id" CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision" @@ -233,7 +238,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = ( "cmock", # Unit testing mock framework - ESPHome doesn't use IDF's testing "console", # Console REPL - unused by ESPHome; espressif/mdns pulls it back when configured "driver", # Legacy driver shim - only needed by esp32_touch, esp32_can for legacy headers - "esp-tls", # TLS wrapper - re-included by http_request, mqtt, web_server_idf + "esp-tls", # TLS wrapper - re-included by request_tls() "esp_adc", # ADC driver - only needed by adc component "esp_coex", # WiFi/BT coexistence - re-included by esp32_ble_tracker, zigbee; esp_wifi/bt pull it back "esp_driver_cam", # Camera driver - the esp32-camera managed component pulls it back @@ -318,6 +323,34 @@ ARDUINO_EXCLUDED_IDF_COMPONENTS = ( "joltwallet__littlefs", # LittleFS - ESPHome doesn't use filesystem ) +# Entries arduino-esp32 only declares below the given IDF version; stubbing one past +# it clashes with ESPHome's own managed component of the same short name. +ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF: dict[str, cv.Version] = { + "espressif__libsodium": cv.Version(6, 0, 0), +} + + +def arduino_bundles_libsodium() -> bool: + """arduino-esp32 ships its own libsodium below IDF 6.0.""" + return ( + CORE.using_arduino + and idf_version() + < ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF["espressif__libsodium"] + ) + + +def arduino_excluded_idf_components() -> set[str]: + """The arduino-bundled components to stub for this build's IDF version.""" + version = idf_version() + return { + component + for component in ARDUINO_EXCLUDED_IDF_COMPONENTS + if (max_version := ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF.get(component)) + is None + or version < max_version + } + + # Mapping of Arduino libraries to IDF managed components they require # When an Arduino library is enabled via cg.add_library(), these components # are automatically un-stubbed from ARDUINO_EXCLUDED_IDF_COMPONENTS. @@ -518,9 +551,88 @@ assert all(variant in CPU_FREQUENCIES for variant in VARIANTS) FULL_CPU_FREQUENCIES = set(itertools.chain.from_iterable(CPU_FREQUENCIES.values())) +_SDKCONFIG_CPU_FREQUENCY_OPTION = "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ" +_SDKCONFIG_CPU_FREQUENCY_PREFIX = f"{_SDKCONFIG_CPU_FREQUENCY_OPTION}_" +# Older IDF versions named the CPU frequency options after the chip +_LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS = frozenset( + {VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3} +) + + +def _get_sdkconfig_cpu_frequencies( + sdkconfig_options: dict[str, str], variant: str +) -> tuple[set[int], set[int]]: + """Return the CPU frequencies in MHz that sdkconfig_options selects and disables.""" + # (integer option, choice option prefix) pairs that can carry a frequency + keys = [(_SDKCONFIG_CPU_FREQUENCY_OPTION, _SDKCONFIG_CPU_FREQUENCY_PREFIX)] + if variant in _LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS: + keys.append( + ( + f"CONFIG_{variant}_DEFAULT_CPU_FREQ_MHZ", + f"CONFIG_{variant}_DEFAULT_CPU_FREQ_", + ) + ) + selected: set[int] = set() + disabled: set[int] = set() + for integer_key, choice_prefix in keys: + if (value := sdkconfig_options.get(integer_key)) is not None: + try: + selected.add(int(value)) + except ValueError as err: + raise cv.Invalid( + f"{integer_key} must be an integer MHz value", + path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS], + ) from err + for name, value in sdkconfig_options.items(): + mhz = name.removeprefix(choice_prefix) + if mhz == name or not mhz.isdigit(): + continue + if value.lower() == "y": + selected.add(int(mhz)) + elif value.lower() == "n": + disabled.add(int(mhz)) + else: + raise cv.Invalid( + f"{name} must be y or n", + path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS], + ) + return selected, disabled + + def set_core_data(config): - cpu_frequency = config.get(CONF_CPU_FREQUENCY, None) + cpu_frequency = config.get(CONF_CPU_FREQUENCY) variant = config[CONF_VARIANT] + sdkconfig_path = [CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS] + selected, disabled = _get_sdkconfig_cpu_frequencies( + config[CONF_FRAMEWORK][CONF_SDKCONFIG_OPTIONS], variant + ) + if len(selected) > 1: + conflicting = ", ".join(f"{mhz}MHz" for mhz in sorted(selected)) + raise cv.Invalid( + f"sdkconfig_options contains conflicting CPU frequencies ({conflicting})", + path=sdkconfig_path, + ) + sdkconfig_cpu_frequency = f"{selected.pop()}MHZ" if selected else None + if cpu_frequency is None and sdkconfig_cpu_frequency is not None: + if sdkconfig_cpu_frequency not in CPU_FREQUENCIES[variant]: + raise cv.Invalid( + f"sdkconfig_options selects {sdkconfig_cpu_frequency}, which {variant} does not support", + path=sdkconfig_path, + ) + _LOGGER.warning( + "sdkconfig_options contains a CPU frequency setting; using %s. " + "Set 'esp32.cpu_frequency' to configure it directly.", + sdkconfig_cpu_frequency, + ) + cpu_frequency = sdkconfig_cpu_frequency + elif ( + sdkconfig_cpu_frequency is not None and sdkconfig_cpu_frequency != cpu_frequency + ): + raise cv.Invalid( + f"esp32.cpu_frequency ({cpu_frequency}) conflicts with sdkconfig_options " + f"({sdkconfig_cpu_frequency})", + path=sdkconfig_path, + ) # if not specified in config, default to the maximum supported frequency # (ESP32-P4 engineering samples are limited to 360MHz, non-engineering can do 400MHz) if cpu_frequency is None: @@ -529,12 +641,17 @@ def set_core_data(config): cpu_frequency = "360MHZ" else: cpu_frequency = choices[-1] - config[CONF_CPU_FREQUENCY] = cpu_frequency elif cpu_frequency not in CPU_FREQUENCIES[variant]: raise cv.Invalid( f"Invalid CPU frequency '{cpu_frequency}' for {config[CONF_VARIANT]}", path=[CONF_CPU_FREQUENCY], ) + if int(cpu_frequency[:-3]) in disabled: + raise cv.Invalid( + f"sdkconfig_options disables the selected CPU frequency ({cpu_frequency})", + path=sdkconfig_path, + ) + config[CONF_CPU_FREQUENCY] = cpu_frequency if variant == VARIANT_ESP32P4 and cpu_frequency == "400MHZ": _LOGGER.warning( @@ -638,20 +755,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"): @@ -701,9 +827,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) @@ -770,6 +897,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, @@ -1595,6 +1813,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) @@ -1782,6 +2023,7 @@ CONF_DISABLE_OCD_AWARE = "disable_ocd_aware" CONF_DISABLE_USB_SERIAL_JTAG_SECONDARY = "disable_usb_serial_jtag_secondary" CONF_DISABLE_DEV_NULL_VFS = "disable_dev_null_vfs" CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert" +CONF_DISABLE_MBEDTLS_TLS = "disable_mbedtls_tls" CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7" CONF_DISABLE_MBEDTLS_TLS_SERVER = "disable_mbedtls_tls_server" CONF_DISABLE_MBEDTLS_TLS_EXTRAS = "disable_mbedtls_tls_extras" @@ -1797,12 +2039,7 @@ KEY_VFS_TERMIOS_REQUIRED = "vfs_termios_required" # Feature requirement tracking - components can call require_* functions to re-enable # These are stored in CORE.data[KEY_ESP32] dict KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED = "usb_serial_jtag_secondary_required" -KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required" -KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required" -KEY_MBEDTLS_TLS_SERVER_REQUIRED = "mbedtls_tls_server_required" -KEY_MBEDTLS_TLS_EXTRAS_REQUIRED = "mbedtls_tls_extras_required" KEY_FATFS_REQUIRED = "fatfs_required" -KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required" KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required" KEY_LIBC_PICOLIBC_NEWLIB_COMPAT_REQUIRED = "libc_picolibc_newlib_compat_required" @@ -1834,6 +2071,17 @@ def require_vfs_termios() -> None: CORE.data[KEY_VFS_TERMIOS_REQUIRED] = True +def allow_insecure_tls() -> None: + """Let esp-tls connect without verifying the server certificate. + + The two sdkconfig flags are global, so a component asking for an unverified + connection switches them on for the whole build; they are never switched off + here because another component may have asked first. + """ + add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) + add_idf_sdkconfig_option("CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True) + + def require_certificate_bundle() -> None: """Enable the mbedTLS root certificate bundle for this build. @@ -1841,6 +2089,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 @@ -1866,33 +2117,37 @@ def require_usb_serial_jtag_secondary() -> None: CORE.data[KEY_ESP32][KEY_USB_SERIAL_JTAG_SECONDARY_REQUIRED] = True -def require_mbedtls_peer_cert() -> None: - """Mark that mbedTLS peer certificate retention is required by a component. +def require_mbedtls_ecp() -> None: + """Keep mbedTLS elliptic curve support (ECDH/ECDSA) without requesting TLS. - Call this from components that need access to the peer certificate after - the TLS handshake is complete. This prevents CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE - from being disabled. + Call this from components that sign or verify with ECDSA outside a TLS + handshake (openthread's SRP host key). WiFi, Bluetooth and secure boot + select it through Kconfig on their own. """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_PEER_CERT_REQUIRED] = True + _mbedtls_sdkconfig().ecp_required = True + + +def require_mbedtls_peer_cert() -> None: + """Keep the peer certificate after the TLS handshake (CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE). + + A user sdkconfig_options value takes precedence. + """ + _mbedtls_sdkconfig().peer_cert_required = True def require_mbedtls_pkcs7() -> None: - """Mark that mbedTLS PKCS#7 support is required by a component. - - Call this from components that need PKCS#7 certificate validation. - This prevents CONFIG_MBEDTLS_PKCS7_C from being disabled. - """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_PKCS7_REQUIRED] = True + """Keep mbedTLS PKCS#7 support (CONFIG_MBEDTLS_PKCS7_C). A user sdkconfig_options value takes precedence.""" + _mbedtls_sdkconfig().pkcs7_required = True def require_mbedtls_tls_server() -> None: - """Mark that the mbedTLS server-side TLS/DTLS handshake is required. + """Widen the TLS role to include the server-side handshake. - Call this from components that accept TLS connections (OpenThread's DTLS - commissioner does). This prevents CONFIG_MBEDTLS_TLS_CLIENT_ONLY from - being selected. + Only affects builds where TLS is compiled in; it prevents + CONFIG_MBEDTLS_TLS_CLIENT_ONLY from being selected. A component that + actually opens or accepts TLS/DTLS sessions must also call request_tls(). """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] = True + _mbedtls_sdkconfig().tls_server_required = True def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: @@ -1905,18 +2160,14 @@ def require_mbedtls_tls_extras(options: Iterable[str] | None = None) -> None: servers ESPHome cannot vet (wpa_supplicant's EAP client). A user-supplied sdkconfig_options value is never overridden either. """ - required = CORE.data[KEY_ESP32].setdefault(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) - required.update(MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options) + _mbedtls_sdkconfig().tls_extras_required.update( + MBEDTLS_TLS_EXTRA_OPTIONS if options is None else options + ) def require_mbedtls_sha512() -> None: - """Mark that mbedTLS SHA-384/SHA-512 support is required by a component. - - Call this from components that need to verify TLS certificates or signatures - using SHA-384 or SHA-512 algorithms. This prevents CONFIG_MBEDTLS_SHA384_C - and CONFIG_MBEDTLS_SHA512_C from being disabled. - """ - CORE.data[KEY_ESP32][KEY_MBEDTLS_SHA512_REQUIRED] = True + """Keep mbedTLS SHA-384/SHA-512 (CONFIG_MBEDTLS_SHA384_C / CONFIG_MBEDTLS_SHA512_C).""" + _mbedtls_sdkconfig().sha512_required = True def idf_version() -> cv.Version: @@ -2035,6 +2286,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 ), @@ -2069,6 +2321,7 @@ FRAMEWORK_SCHEMA = cv.Schema( cv.Optional(CONF_DISABLE_DEV_NULL_VFS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_MBEDTLS_TLS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_TLS_SERVER, default=True): cv.boolean, cv.Optional(CONF_DISABLE_MBEDTLS_TLS_EXTRAS, default=True): cv.boolean, cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean, @@ -2361,6 +2614,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. @@ -2420,34 +2687,103 @@ MBEDTLS_TLS_ROLE_OPTIONS = ( ) -@coroutine_with_priority(CoroPriority.FINAL) -async def _reconcile_mbedtls_tls_sdkconfig( - disable_tls_server: bool, disable_tls_extras: bool -) -> None: - """Trim mbedTLS to what a TLS client needs unless a component asked otherwise. +# User sdkconfig_options that mean "keep TLS on" when set to y. The +# OpenThread entries compile its DTLS secure transport in, which links +# mbedtls_ssl_*. +_MBEDTLS_TLS_ON_OPTIONS = ( + "CONFIG_MBEDTLS_TLS_ENABLED", + "CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", + "CONFIG_MBEDTLS_TLS_SERVER_ONLY", + "CONFIG_MBEDTLS_TLS_CLIENT_ONLY", + "CONFIG_OPENTHREAD_COMMISSIONER", + "CONFIG_OPENTHREAD_JOINER", + "CONFIG_OPENTHREAD_BORDER_AGENT_ENABLE", + # Border router defaults the border agent (and its DTLS) on. + "CONFIG_OPENTHREAD_BORDER_ROUTER", + # Enterprise WiFi selects TLS back on (see the TLS-off block). + "CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", +) +# Any user option under these prefixes only makes sense with TLS compiled in. +_TLS_OPTION_PREFIXES = ("CONFIG_ESP_TLS_", "CONFIG_MBEDTLS_SSL_", "CONFIG_ESP_HTTPS_") - Runs at FINAL priority so every require_mbedtls_tls_server() and - require_mbedtls_tls_extras() call has happened. Only the server-side - handshake (~7 KB) is a separate option; nothing in ESPHome accepts TLS - connections, but OpenThread's DTLS commissioner does. A user-supplied - sdkconfig_options value always wins; for the TLS role choice, any member - the user set leaves the whole choice alone so the pair cannot conflict. + +def _user_sdkconfig_wants_tls(options: dict[str, Any]) -> bool: + """True when sdkconfig_options turn TLS on or tune something under it; an `n` is never a request.""" + return any( + (name in _MBEDTLS_TLS_ON_OPTIONS and value == "y") + or (name == "CONFIG_MBEDTLS_TLS_DISABLED" and value == "n") + or (name.startswith(_TLS_OPTION_PREFIXES) and value != "n") + for name, value in options.items() + ) + + +@coroutine_with_priority(CoroPriority.FINAL) +async def _reconcile_mbedtls_sdkconfig() -> None: + """Reconcile the mbedTLS sdkconfig flags after every request_tls() / require_mbedtls_*() call. + + mbedtls cannot be excluded from an IDF build (bootloader_support needs its + SHA-256), but with no TLS user the ssl_*.c sources and the TLS-only crypto + compile to empty objects. When TLS stays in, it is trimmed to the client + role and the legacy handshake extras are dropped. User sdkconfig_options + win; a user-chosen TLS role leaves the whole choice alone. """ - data = CORE.data[KEY_ESP32] - sdkconfig = data[KEY_SDKCONFIG_OPTIONS] - if ( - disable_tls_server - and not data.get(KEY_MBEDTLS_TLS_SERVER_REQUIRED, False) - and not any(option in sdkconfig for option in MBEDTLS_TLS_ROLE_OPTIONS) + data = _mbedtls_sdkconfig() + idf6 = idf_version() >= cv.Version(6, 0, 0) + opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] + + if _mbedtls_tls_compiled_out(): + # IDF 6 made CONFIG_MBEDTLS_TLS_ENABLED a normal bool; on IDF 5 it has + # no prompt and is only reachable through the "None" TLS role choice. + if idf6: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_ENABLED", False) + else: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_TLS_DISABLED", True) + # Enterprise WiFi selects TLS back on; wifi writes this itself, but + # esp_wifi can also be in the build without a wifi: block (openthread). + set_idf_sdkconfig_default("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", False) + # WiFi (ESP_WIFI_MBEDTLS_CRYPTO) and Bluetooth deliberately stay on + # the select-wins path: an unconditional request from request_wifi() + # would defeat the ECP trim for users who disable that select. + if not data.ecp_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_ECP_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PEM_WRITE_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CRL_PARSE_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_X509_CSR_PARSE_C", False) + elif ( + # TLS stays in: trim it to the client role unless a component accepts + # TLS connections or the user already chose a role. + data.disable_tls_server + and not data.tls_server_required + and not any(option in opts for option in MBEDTLS_TLS_ROLE_OPTIONS) ): add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_CLIENT_ONLY", True) add_idf_sdkconfig_option("CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT", False) - if disable_tls_extras: - required = data.get(KEY_MBEDTLS_TLS_EXTRAS_REQUIRED, set()) + + # The extras run either way: CCM and deterministic ECDSA are plain + # crypto, not TLS-gated, so they matter even with TLS compiled out. + if data.disable_tls_extras: for option in MBEDTLS_TLS_EXTRA_OPTIONS: - if option not in required: + if option not in data.tls_extras_required: set_idf_sdkconfig_default(option, False) + # Keeping the peer certificate costs ~4KB heap per connection. + if data.peer_cert_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", True) + elif data.disable_peer_cert: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE", False) + + if data.pkcs7_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", True) + elif data.disable_pkcs7: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_PKCS7_C", False) + + # SHA-384 shares the SHA-512 compression function, so both go together. + # Only IDF 6.0's PSA engine links a ~3KB software fallback for them; on + # IDF 5 they are a single hardware-only option with no code size cost. + if idf6 and not data.sha512_required: + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA384_C", False) + set_idf_sdkconfig_default("CONFIG_MBEDTLS_SHA512_C", False) + @coroutine_with_priority(CoroPriority.FINAL) async def _reconcile_network_sdkconfig() -> None: @@ -2684,6 +3020,11 @@ async def to_code(config): # produce reproducible outputs and downstream tooling can reuse artifacts. add_idf_sdkconfig_option("CONFIG_APP_REPRODUCIBLE_BUILD", True) + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + # --undefined: libsrc.a is scanned before the IDF libraries that also register them. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + cg.add_build_flag("-Wl,--undefined=__wrap___cxa_atexit") + if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF: cg.add_build_flag("-DUSE_ESP_IDF") cg.add_build_flag("-DUSE_ESP32_FRAMEWORK_ESP_IDF") @@ -2794,6 +3135,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. @@ -2831,6 +3174,10 @@ async def to_code(config): # Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000) + # Main loop wakes use notification index 1; index 0 stays free for ESP-IDF waits + # such as pthread_join(), which a stray main-loop wake would otherwise end early. + add_idf_sdkconfig_option("CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES", 2) + # Place non-ISR FreeRTOS functions into flash instead of IRAM # This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM. # In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH @@ -2886,7 +3233,7 @@ async def to_code(config): # Set default CPU frequency add_idf_sdkconfig_option( - f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True + f"{_SDKCONFIG_CPU_FREQUENCY_PREFIX}{config[CONF_CPU_FREQUENCY][:-3]}", True ) # Apply LWIP optimization settings @@ -2913,6 +3260,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 @@ -3057,6 +3415,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 @@ -3127,38 +3489,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) @@ -3168,12 +3498,14 @@ async def to_code(config): # FINAL priority: runs after every require_certificate_bundle() call CORE.add_job(_reconcile_certificate_bundle_sdkconfig) - # FINAL priority: runs after every require_mbedtls_tls_*() call - CORE.add_job( - _reconcile_mbedtls_tls_sdkconfig, - advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER], - advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS], - ) + # FINAL priority: runs after every request_tls() / require_mbedtls_*() call + mbedtls = _mbedtls_sdkconfig() + mbedtls.disable_tls = advanced[CONF_DISABLE_MBEDTLS_TLS] + mbedtls.disable_tls_server = advanced[CONF_DISABLE_MBEDTLS_TLS_SERVER] + mbedtls.disable_tls_extras = advanced[CONF_DISABLE_MBEDTLS_TLS_EXTRAS] + mbedtls.disable_peer_cert = advanced[CONF_DISABLE_MBEDTLS_PEER_CERT] + mbedtls.disable_pkcs7 = advanced[CONF_DISABLE_MBEDTLS_PKCS7] + CORE.add_job(_reconcile_mbedtls_sdkconfig) # FINAL: require_*() calls can come from to_code at or below this priority, so an # inline read would be iteration-order-dependent; reconcile once after every job ran. @@ -3206,6 +3538,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 @@ -3447,6 +3781,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 @@ -3460,9 +3818,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) @@ -3472,8 +3828,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(): @@ -3515,22 +3880,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] @@ -3708,10 +4061,3 @@ def process_stacktrace(config, line, backtrace_state): _decode_pc(config, addr.group()) return backtrace_state - - -# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which -# are instantiated solely by the pin schema codegen (esp32_pin_to_code) -FILTER_SOURCE_FILES = filter_source_files_from_defines( - {"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"} -) diff --git a/esphome/components/esp32/atexit_stubs.cpp b/esphome/components/esp32/atexit_stubs.cpp new file mode 100644 index 0000000000..1230a56681 --- /dev/null +++ b/esphome/components/esp32/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables (about 400 bytes of RAM on ESP32). + */ + +#include "esphome/core/defines.h" + +#ifdef USE_ESP32 + +namespace esphome::esp32 {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_ESP32 diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index a0c9809c50..33751ea7d5 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -23,6 +23,7 @@ KEY_EXTRA_BUILD_FILES = "extra_build_files" KEY_CERT_BUNDLE = "cert_bundle" KEY_FULL_CERT_BUNDLE = "full_cert_bundle" KEY_NETWORK_SDKCONFIG = "network_sdkconfig" +KEY_MBEDTLS_SDKCONFIG = "mbedtls_sdkconfig" VARIANT_ESP32 = "ESP32" VARIANT_ESP32C2 = "ESP32C2" diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 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/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index b72a2777c7..182bc5f2d6 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -161,7 +161,7 @@ static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-c namespace esphome::esp32 { -static const char *const TAG = "esp32.crash"; +ESPHOME_LOG_TAG(TAG, "esp32.crash"); // RAM copy of the build timestamp. The generated constant lives in flash, // which the panic handler must not read (cache may be disabled during @@ -531,6 +531,9 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) { // Xtensa: walk the backtrace using the public API if (info->frame != nullptr) { auto *xt_frame = (XtExcFrame *) info->frame; + // IDF leaves addr unset for SoC-level panics (cache error, interrupt WDT) + if (info->pseudo_excause) + s_raw_crash_data.pc = xt_frame->pc; if (!g_panic_abort) { // Abort-class frames carry no useful cause/vaddr: TWDT task snapshots // never wrote them and abort() traps describe only the synthetic trap. diff --git a/esphome/components/esp32/gpio.cpp b/esphome/components/esp32/gpio.cpp index 74665f3126..d9e303c973 100644 --- a/esphome/components/esp32/gpio.cpp +++ b/esphome/components/esp32/gpio.cpp @@ -1,9 +1,10 @@ #include "esphome/core/defines.h" -// Also defines the core ISRInternalGPIOPin methods; those are only reachable -// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely. -#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO) +// Always built: InternalGPIOPin is this class, so components that merely hold a +// pointer to it reference these methods even in configs without a pin. +#ifdef USE_ESP32 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include "driver/gpio.h" #include "driver/rtc_io.h" @@ -23,14 +24,14 @@ namespace esphome { namespace esp32 { -static const char *const TAG = "esp32"; +ESPHOME_LOG_TAG(TAG, "esp32"); 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) @@ -49,17 +50,52 @@ struct ISRPinArg { gpio_num_t pin; gpio::Flags flags; bool inverted; +#ifdef USE_GPIO_HOLD + bool hold; +#endif #if defined(USE_ESP32_VARIANT_ESP32) bool use_rtc; int rtc_pin; #endif }; +#ifdef USE_GPIO_HOLD +// Re-latch a held pad onto the values just written to its registers. +static inline void refresh_hold(const ESP32InternalGPIOPin &pin) { + pin.set_hold(false); + pin.set_hold(true); +} + +static inline void IRAM_ATTR isr_refresh_hold(const ISRPinArg *arg) { + if (!arg->hold) + return; +#if defined(USE_ESP32_VARIANT_ESP32) + if (arg->use_rtc) { + rtcio_hal_hold_disable(arg->rtc_pin); + rtcio_hal_hold_enable(arg->rtc_pin); + return; + } +#elif defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + // GPIO0-21 are held from the RTC domain; the digital hold mask is zero for them + if (arg->pin < SOC_RTCIO_PIN_COUNT) { + rtcio_hal_hold_disable(arg->pin); + rtcio_hal_hold_enable(arg->pin); + return; + } +#endif + gpio_hal_hold_dis(&GPIO_HAL, arg->pin); + gpio_hal_hold_en(&GPIO_HAL, arg->pin); +} +#endif + ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const { auto *arg = new ISRPinArg{}; // NOLINT(cppcoreguidelines-owning-memory) arg->pin = this->get_pin_num(); arg->flags = gpio::FLAG_NONE; arg->inverted = this->pin_flags_.inverted; +#ifdef USE_GPIO_HOLD + arg->hold = this->get_hold(); +#endif #if defined(USE_ESP32_VARIANT_ESP32) arg->use_rtc = rtc_gpio_is_valid_gpio(this->get_pin_num()); if (arg->use_rtc) @@ -68,7 +104,7 @@ ISRInternalGPIOPin ESP32InternalGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ESP32InternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ESP32InternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { gpio_int_type_t idf_type = GPIO_INTR_ANYEDGE; switch (type) { case gpio::INTERRUPT_RISING_EDGE: @@ -104,7 +140,23 @@ size_t ESP32InternalGPIOPin::dump_summary(char *buffer, size_t len) const { return snprintf(buffer, len, "GPIO%" PRIu32, static_cast(this->pin_)); } +#ifdef USE_GPIO_HOLD +void ESP32InternalGPIOPin::set_hold(bool hold) const { + if (!this->get_hold()) + return; + if (hold) { + gpio_hold_en(this->get_pin_num()); + } else { + gpio_hold_dis(this->get_pin_num()); + } +} +#endif + void ESP32InternalGPIOPin::setup() { +#ifdef USE_GPIO_HOLD + // Hold before gpio_config so the pad keeps its state while the registers change + this->set_hold(true); +#endif gpio_config_t conf{}; conf.pin_bit_mask = 1ULL << static_cast(this->pin_); conf.mode = flags_to_mode(this->flags_); @@ -112,9 +164,25 @@ void ESP32InternalGPIOPin::setup() { conf.pull_down_en = this->flags_ & gpio::FLAG_PULLDOWN ? GPIO_PULLDOWN_ENABLE : GPIO_PULLDOWN_DISABLE; conf.intr_type = GPIO_INTR_DISABLE; gpio_config(&conf); +#ifdef CONFIG_FREERTOS_USE_TICKLESS_IDLE + // If auto light sleep is used gpio sleep mode needs to be disabled for the pin + gpio_sleep_sel_dis(this->get_pin_num()); +#endif if (this->flags_ & gpio::FLAG_OUTPUT) { gpio_set_drive_capability(this->get_pin_num(), this->get_drive_strength()); } +#ifdef USE_GPIO_HOLD + // gpio_hold_dis logs an error on input-only pads + if (GPIO_IS_VALID_OUTPUT_GPIO(this->get_pin_num())) { + if (!this->get_hold()) { + // Release a hold left behind by an earlier boot + gpio_hold_dis(this->get_pin_num()); + } else if (this->flags_ & gpio::FLAG_INPUT) { + // Inputs apply the config now so reads work; outputs wait for the first write + refresh_hold(*this); + } + } +#endif } void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { @@ -129,6 +197,9 @@ void ESP32InternalGPIOPin::pin_mode(gpio::Flags flags) { pull_mode = GPIO_PULLDOWN_ONLY; } gpio_set_pull_mode(this->get_pin_num(), pull_mode); +#ifdef USE_GPIO_HOLD + refresh_hold(*this); +#endif } bool ESP32InternalGPIOPin::digital_read() { @@ -136,6 +207,9 @@ bool ESP32InternalGPIOPin::digital_read() { } void ESP32InternalGPIOPin::digital_write(bool value) { gpio_set_level(this->get_pin_num(), value != this->pin_flags_.inverted ? 1 : 0); +#ifdef USE_GPIO_HOLD + refresh_hold(*this); +#endif } void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->get_pin_num()); } @@ -143,6 +217,8 @@ void ESP32InternalGPIOPin::detach_interrupt() const { gpio_intr_disable(this->ge using namespace esp32; +// NOLINTBEGIN(clang-analyzer-core.FixedAddressDereference) -- some gpio_hal functions use MMIO at fixed addresses +// internally bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); return bool(gpio_hal_get_level(&GPIO_HAL, arg->pin)) != arg->inverted; @@ -151,6 +227,9 @@ bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { void IRAM_ATTR ISRInternalGPIOPin::digital_write(bool value) { auto *arg = reinterpret_cast(this->arg_); gpio_hal_set_level(&GPIO_HAL, arg->pin, value != arg->inverted); +#ifdef USE_GPIO_HOLD + isr_refresh_hold(arg); +#endif } void IRAM_ATTR ISRInternalGPIOPin::clear_interrupt() { @@ -202,9 +281,13 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) { gpio_hal_input_disable(&GPIO_HAL, arg->pin); } } +#ifdef USE_GPIO_HOLD + isr_refresh_hold(arg); +#endif arg->flags = flags; } +// NOLINTEND(clang-analyzer-core.FixedAddressDereference) } // namespace esphome -#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO +#endif // USE_ESP32 diff --git a/esphome/components/esp32/gpio.h b/esphome/components/esp32/gpio.h index aeff5af51c..6ac1673127 100644 --- a/esphome/components/esp32/gpio.h +++ b/esphome/components/esp32/gpio.h @@ -1,7 +1,8 @@ #pragma once #ifdef USE_ESP32 -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" +#include #include namespace esphome::esp32 { @@ -10,7 +11,7 @@ namespace esphome::esp32 { static_assert(GPIO_NUM_MAX <= 256, "gpio_num_t has too many values for uint8_t"); static_assert(GPIO_DRIVE_CAP_MAX <= 4, "gpio_drive_cap_t has too many values for 2-bit field"); -class ESP32InternalGPIOPin final : public InternalGPIOPin { +class ESP32InternalGPIOPin final : public GPIOPin { public: void set_pin(gpio_num_t pin) { this->pin_ = static_cast(pin); } void set_inverted(bool inverted) { this->pin_flags_.inverted = inverted; } @@ -24,16 +25,30 @@ class ESP32InternalGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return this->pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return this->pin_; } gpio::Flags get_flags() const override { return this->flags_; } - bool is_inverted() const override { return this->pin_flags_.inverted; } + bool is_inverted() const { return this->pin_flags_.inverted; } + bool is_internal() override { return true; } gpio_num_t get_pin_num() const { return static_cast(this->pin_); } gpio_drive_cap_t get_drive_strength() const { return static_cast(this->pin_flags_.drive_strength); } +#ifdef USE_GPIO_HOLD + /// Apply or release the pad hold. Does nothing unless the pin is configured with hold_state. + void set_hold(bool hold) const; + bool is_held() const override { + if (!this->get_hold()) + return false; + esp_reset_reason_t reason = esp_reset_reason(); + return reason == ESP_RST_DEEPSLEEP || reason == ESP_RST_SW; + } +#endif protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; // Memory layout: 8 bytes total on 32-bit systems // - 3 bytes for members below diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 98aac209ec..7753574bc8 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -5,6 +5,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -248,6 +249,7 @@ ESP32_PIN_SCHEMA = cv.All( cv.float_with_unit("current", "mA", optional_unit=True), cv.enum(DRIVE_STRENGTHS), ), + cv.Optional(CONF_HOLD_STATE, default=False): cv.boolean, } ), validate_gpio_pin, @@ -257,7 +259,6 @@ ESP32_PIN_SCHEMA = cv.All( @pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA) async def esp32_pin_to_code(config): - cg.add_define("USE_ESP32_INTERNAL_GPIO") var = cg.new_Pvariable(config[CONF_ID]) num = config[CONF_NUMBER] cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}"))) @@ -267,7 +268,11 @@ async def esp32_pin_to_code(config): cg.add(var.set_inverted(True)) if CONF_DRIVE_STRENGTH in config: cg.add(var.set_drive_strength(config[CONF_DRIVE_STRENGTH])) - cg.add(var.set_flags(pins.gpio_flags_expr(config[CONF_MODE]))) + flags = pins.gpio_flags_expr(config[CONF_MODE]) + if config[CONF_HOLD_STATE]: + flags = flags | cg.gpio_Flags.FLAG_HOLD + cg.add_define("USE_GPIO_HOLD") + cg.add(var.set_flags(flags)) return var diff --git a/esphome/components/esp32/gpio_common.py b/esphome/components/esp32/gpio_common.py new file mode 100644 index 0000000000..a12bebf6e9 --- /dev/null +++ b/esphome/components/esp32/gpio_common.py @@ -0,0 +1,26 @@ +"""Pin checks shared by the ESP32 variant validators.""" + +from logging import Logger +from typing import Any + +from esphome.components.const import CONF_HOLD_STATE +from esphome.const import CONF_NUMBER + + +def check_usb_jtag_pin(num: int, usb_jtag_pins: set[int], logger: Logger) -> None: + if num in usb_jtag_pins: + logger.warning( + "GPIO%d is used by the USB-Serial-JTAG interface." + " Using this pin as GPIO will conflict with USB-Serial-JTAG.", + num, + ) + + +def check_usb_jtag_hold( + conf: dict[str, Any], usb_jtag_pins: set[int], logger: Logger +) -> None: + num = conf[CONF_NUMBER] + if conf.get(CONF_HOLD_STATE) and num in usb_jtag_pins: + logger.warning( + "GPIO%d cannot hold at low level during wakeup from deep sleep.", num + ) diff --git a/esphome/components/esp32/gpio_esp32.py b/esphome/components/esp32/gpio_esp32.py index fec257f90b..11c62359c1 100644 --- a/esphome/components/esp32/gpio_esp32.py +++ b/esphome/components/esp32/gpio_esp32.py @@ -1,6 +1,7 @@ import logging from typing import Any +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_INPUT, @@ -53,6 +54,7 @@ def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]: is_output = mode[CONF_OUTPUT] is_pullup = mode[CONF_PULLUP] is_pulldown = mode[CONF_PULLDOWN] + is_hold = value.get(CONF_HOLD_STATE) if is_input: # All ESP32 pins support input mode @@ -70,6 +72,14 @@ def esp32_validate_supports(value: dict[str, Any]) -> dict[str, Any]: raise cv.Invalid( f"GPIO{num} (34-39) does not support pulldowns.", [CONF_MODE, CONF_PULLDOWN] ) + if is_hold and 34 <= num <= 39: + raise cv.Invalid( + f"GPIO{num} (34-39) is input-only and cannot be held.", + [CONF_HOLD_STATE], + ) + if is_hold and num == 20: + # Not in the digital hold mask and not an RTC pad, so the driver cannot hold it + raise cv.Invalid("GPIO20 has no hold function.", [CONF_HOLD_STATE]) check_strapping_pin(value, _ESP32_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c3.py b/esphome/components/esp32/gpio_esp32_c3.py index 6eb002f3f0..c4624a93a4 100644 --- a/esphome/components/esp32/gpio_esp32_c3.py +++ b/esphome/components/esp32/gpio_esp32_c3.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32C3_SPI_PSRAM_PINS = { 12: "SPIHD", 13: "SPIWP", @@ -28,12 +30,7 @@ def esp32_c3_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-C3s and is already used by the SPI/PSRAM interface (function: {_ESP32C3_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32C3_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32C3_USB_JTAG_PINS, _LOGGER) return value @@ -49,6 +46,7 @@ def esp32_c3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C3_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32C3_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py index 639ed64c9e..b3f4c319c6 100644 --- a/esphome/components/esp32/gpio_esp32_c5.py +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c5/include/hal/i2c_ll.h _ESP32C5_I2C_LP_PINS = {"SDA": 2, "SCL": 3} @@ -20,6 +22,8 @@ _ESP32C5_SPI_PSRAM_PINS = { _ESP32C5_STRAPPING_PINS = {2, 7, 27, 28} +_ESP32C5_USB_JTAG_PINS = {13, 14} + _LOGGER = logging.getLogger(__name__) @@ -31,6 +35,7 @@ def esp32_c5_validate_gpio_pin(value: int) -> int: f"This pin cannot be used on ESP32-C5s and is already used by the SPI/PSRAM interface (function: {_ESP32C5_SPI_PSRAM_PINS[value]})" ) + check_usb_jtag_pin(value, _ESP32C5_USB_JTAG_PINS, _LOGGER) return value @@ -45,6 +50,8 @@ def esp32_c5_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C5_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32C5_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index bd7bb9e220..9eba375e26 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c6/include/hal/i2c_ll.h _ESP32C6_I2C_LP_PINS = {"SDA": 6, "SCL": 7} @@ -32,12 +34,7 @@ def esp32_c6_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"This pin cannot be used on ESP32-C6s and is already used by the SPI/PSRAM interface (function: {_ESP32C6_SPI_PSRAM_PINS[value]})" ) - if value in _ESP32C6_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32C6_USB_JTAG_PINS, _LOGGER) return value @@ -52,6 +49,7 @@ def esp32_c6_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C6_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32C6_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_c61.py b/esphome/components/esp32/gpio_esp32_c61.py index 2f3abe6a0f..3ae8f1c292 100644 --- a/esphome/components/esp32/gpio_esp32_c61.py +++ b/esphome/components/esp32/gpio_esp32_c61.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # GPIO14-17, GPIO19-21 are used for SPI flash/PSRAM _ESP32C61_SPI_PSRAM_PINS = { 14: "SPICS0", @@ -18,6 +20,8 @@ _ESP32C61_SPI_PSRAM_PINS = { _ESP32C61_STRAPPING_PINS = {8, 9} +_ESP32C61_USB_JTAG_PINS = {12, 13} + _LOGGER = logging.getLogger(__name__) @@ -29,6 +33,7 @@ def esp32_c61_validate_gpio_pin(value: int) -> int: f"This pin cannot be used on ESP32-C61s and is already used by the SPI/PSRAM interface (function: {_ESP32C61_SPI_PSRAM_PINS[value]})" ) + check_usb_jtag_pin(value, _ESP32C61_USB_JTAG_PINS, _LOGGER) return value @@ -43,5 +48,7 @@ def esp32_c61_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32-C61 pins support input mode pass + check_usb_jtag_hold(value, _ESP32C61_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32C61_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h2.py b/esphome/components/esp32/gpio_esp32_h2.py index 9dd6537694..41520bb4b7 100644 --- a/esphome/components/esp32/gpio_esp32_h2.py +++ b/esphome/components/esp32/gpio_esp32_h2.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32H2_SPI_FLASH_PINS = {6, 7, 15, 16, 17, 18, 19, 20, 21} _ESP32H2_USB_JTAG_PINS = {26, 27} @@ -24,12 +26,7 @@ def esp32_h2_validate_gpio_pin(value: int) -> int: "See https://docs.espressif.com/projects/esp-idf/en/latest/esp32h2/api-reference/peripherals/gpio.html", value, ) - if value in _ESP32H2_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32H2_USB_JTAG_PINS, _LOGGER) return value @@ -44,5 +41,6 @@ def esp32_h2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H2_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32H2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h21.py b/esphome/components/esp32/gpio_esp32_h21.py index 5ab1b7c074..a13fe3e4cc 100644 --- a/esphome/components/esp32/gpio_esp32_h21.py +++ b/esphome/components/esp32/gpio_esp32_h21.py @@ -5,17 +5,22 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Partial set from the ESP-IDF / esptool boot-mode docs: # https://docs.espressif.com/projects/esptool/en/latest/esp32h21/advanced-topics/boot-mode-selection.html # The full list awaits the ESP32-H21 datasheet's "Strapping Pins" section. _ESP32H21_STRAPPING_PINS: set[int] = {13, 14} +_ESP32H21_USB_JTAG_PINS = {17, 18} + _LOGGER = logging.getLogger(__name__) def esp32_h21_validate_gpio_pin(value: int) -> int: if value < 0 or value > 25: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-25)") + check_usb_jtag_pin(value, _ESP32H21_USB_JTAG_PINS, _LOGGER) return value @@ -30,5 +35,7 @@ def esp32_h21_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H21_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32H21_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_h4.py b/esphome/components/esp32/gpio_esp32_h4.py index 86a4d55858..0cbd83c50c 100644 --- a/esphome/components/esp32/gpio_esp32_h4.py +++ b/esphome/components/esp32/gpio_esp32_h4.py @@ -5,17 +5,22 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Partial set from the ESP-IDF / esptool boot-mode docs: # https://docs.espressif.com/projects/esptool/en/latest/esp32h4/advanced-topics/boot-mode-selection.html # The full list awaits the ESP32-H4 datasheet's "Strapping Pins" section. _ESP32H4_STRAPPING_PINS: set[int] = {13, 14} +_ESP32H4_USB_JTAG_PINS = {13, 14} + _LOGGER = logging.getLogger(__name__) def esp32_h4_validate_gpio_pin(value: int) -> int: if value < 0 or value > 39: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-39)") + check_usb_jtag_pin(value, _ESP32H4_USB_JTAG_PINS, _LOGGER) return value @@ -30,5 +35,7 @@ def esp32_h4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32H4_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32H4_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 6e9227c501..69fdf5dd22 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # https://documentation.espressif.com/esp32-p4-chip-revision-v1.3_datasheet_en.pdf _ESP32P4_LP_PINS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} @@ -18,12 +20,7 @@ _LOGGER = logging.getLogger(__name__) def esp32_p4_validate_gpio_pin(value: int) -> int: if value < 0 or value > 54: raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") - if value in _ESP32P4_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32P4_USB_JTAG_PINS, _LOGGER) return value @@ -38,6 +35,7 @@ def esp32_p4_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32P4_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s2.py b/esphome/components/esp32/gpio_esp32_s2.py index 4978f48a1c..0de621789d 100644 --- a/esphome/components/esp32/gpio_esp32_s2.py +++ b/esphome/components/esp32/gpio_esp32_s2.py @@ -1,6 +1,7 @@ import logging from typing import Any +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv from esphome.const import ( CONF_INPUT, @@ -70,6 +71,11 @@ def esp32_s2_validate_supports(value: dict[str, Any]) -> dict[str, Any]: raise cv.Invalid( f"GPIO{num} does not support pulldowns.", [CONF_MODE, CONF_PULLDOWN] ) + if value.get(CONF_HOLD_STATE) and num == 46: + raise cv.Invalid( + f"GPIO{num} is input-only and cannot be held.", + [CONF_HOLD_STATE], + ) check_strapping_pin(value, _ESP32S2_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s3.py b/esphome/components/esp32/gpio_esp32_s3.py index db8c520533..d38c0a8011 100644 --- a/esphome/components/esp32/gpio_esp32_s3.py +++ b/esphome/components/esp32/gpio_esp32_s3.py @@ -12,6 +12,8 @@ from esphome.const import ( from esphome.pins import PIN_SCHEMA_REGISTRY, check_strapping_pin from esphome.types import ConfigType +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + _ESP32S3_SPI_PSRAM_PINS = { 26: "SPICS1", 27: "SPIHD", @@ -53,12 +55,7 @@ def esp32_s3_validate_gpio_pin(value: int) -> int: # These pins are not exposed in GPIO mux (reason unknown) # but they're missing from IO_MUX list in datasheet raise cv.Invalid(f"The pin GPIO{value} is not usable on ESP32-S3s.") - if value in _ESP32S3_USB_JTAG_PINS: - _LOGGER.warning( - "GPIO%d is used by the USB-Serial-JTAG interface." - " Using this pin as GPIO will conflict with USB-Serial-JTAG.", - value, - ) + check_usb_jtag_pin(value, _ESP32S3_USB_JTAG_PINS, _LOGGER) return value @@ -73,6 +70,7 @@ def esp32_s3_validate_supports(value: dict[str, Any]) -> dict[str, Any]: if is_input: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32S3_USB_JTAG_PINS, _LOGGER) check_strapping_pin(value, _ESP32S3_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/gpio_esp32_s31.py b/esphome/components/esp32/gpio_esp32_s31.py index 7ccb7cdb90..a6a3b39193 100644 --- a/esphome/components/esp32/gpio_esp32_s31.py +++ b/esphome/components/esp32/gpio_esp32_s31.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +from .gpio_common import check_usb_jtag_hold, check_usb_jtag_pin + # Per the ESP32-S31 IDF DOCS and datasheet: # https://docs.espressif.com/projects/esp-idf/en/v6.1/esp32s31/api-reference/peripherals/gpio.html # https://documentation.espressif.com/esp32-s31_datasheet_en.pdf @@ -16,6 +18,8 @@ _ESP32S31_STRAPPING_PINS: set[int] = {36, 37, 60, 61} # LP I2C is fixed to GPIO6 (SCL) / GPIO7 (SDA) per the datasheet IO MUX table. _ESP32S31_I2C_LP_PINS = {"SDA": 7, "SCL": 6} +_ESP32S31_USB_JTAG_PINS = {33, 34} + _LOGGER = logging.getLogger(__name__) @@ -28,6 +32,7 @@ def esp32_s31_validate_gpio_pin(value: int) -> int: raise cv.Invalid( f"GPIO{value} is reserved for the SPI flash interface on ESP32-S31 and cannot be used." ) + check_usb_jtag_pin(value, _ESP32S31_USB_JTAG_PINS, _LOGGER) return value @@ -46,6 +51,8 @@ def esp32_s31_validate_supports(value: dict[str, Any]) -> dict[str, Any]: # All ESP32 pins support input mode pass + check_usb_jtag_hold(value, _ESP32S31_USB_JTAG_PINS, _LOGGER) + check_strapping_pin(value, _ESP32S31_STRAPPING_PINS, _LOGGER) return value diff --git a/esphome/components/esp32/hal.h b/esphome/components/esp32/hal.h index 2180f07f6c..c01dcc858e 100644 --- a/esphome/components/esp32/hal.h +++ b/esphome/components/esp32/hal.h @@ -9,6 +9,7 @@ #include #include +#include "esphome/core/main_task.h" #include "esphome/core/time_conversion.h" #ifndef PROGMEM @@ -31,6 +32,10 @@ __attribute__((always_inline)) inline bool in_isr_context() { return xPortInIsrC // NOLINTNEXTLINE(readability-redundant-declaration) extern "C" int64_t esp_timer_get_time(void); +/// Before setup() stores the handle every task counts as background; the wake is a no-op until then. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return xTaskGetCurrentTaskHandle() == esphome_main_task_handle; +} __attribute__((always_inline)) inline void yield() { vPortYield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { vTaskDelay(ms / portTICK_PERIOD_MS); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(esp_timer_get_time()); } diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index 91b4241211..88fccea0ca 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -15,7 +15,7 @@ namespace esphome { -static const char *const TAG = "esp32"; +ESPHOME_LOG_TAG(TAG, "esp32"); bool random_bytes(uint8_t *data, size_t len) { esp_fill_random(data, len); diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index f3d5844cd7..02978ae0f7 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -12,7 +12,7 @@ namespace esphome::esp32 { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); struct NVSData { uint32_t key; diff --git a/esphome/components/esp32_ble/__init__.py b/esphome/components/esp32_ble/__init__.py index 7e97111686..48f2e3c946 100644 --- a/esphome/components/esp32_ble/__init__.py +++ b/esphome/components/esp32_ble/__init__.py @@ -22,6 +22,8 @@ from esphome.components.esp32 import ( request_bluetooth, ) from esphome.components.esp32.const import VARIANT_ESP32C2 +from esphome.components.esp32_hosted import DOMAIN as ESP32_HOSTED_DOMAIN +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -32,8 +34,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 @@ -278,7 +279,7 @@ DEFAULT_MAX_CONNECTIONS = 3 IDF_MAX_CONNECTIONS = 9 # Connection slot tracking keys -KEY_ESP32_BLE = "esp32_ble" +KEY_ESP32_BLE = DOMAIN KEY_USED_CONNECTION_SLOTS = "used_connection_slots" # Export for use by other components (bluetooth_proxy, etc.) @@ -362,7 +363,7 @@ CONFIG_SCHEMA = cv.Schema( cv.positive_int, cv.Range(min=1, max=IDF_MAX_CONNECTIONS) ), cv.Optional(CONF_USE_PSRAM): cv.All( - cv.only_on_esp32, cv.requires_component("psram"), cv.boolean + cv.only_on_esp32, cv.requires_component(PSRAM_DOMAIN), cv.boolean ), } ).extend(cv.COMPONENT_SCHEMA) @@ -405,7 +406,7 @@ def consume_connection_slots( """ def _consume_connection_slots(config: MutableMapping) -> MutableMapping: - data: dict[str, Any] = CORE.data.setdefault(KEY_ESP32_BLE, {}) + data: dict[str, Any] = CORE.data.setdefault(DOMAIN, {}) slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) slots.extend([consumer] * value) return config @@ -419,7 +420,7 @@ def validate_connection_slots(max_connections: int) -> None: if CORE.testing_mode: return - ble_data = CORE.data.get(KEY_ESP32_BLE, {}) + ble_data = CORE.data.get(DOMAIN, {}) used_slots = ble_data.get(KEY_USED_CONNECTION_SLOTS, []) num_used = len(used_slots) @@ -474,13 +475,18 @@ def final_validation(config: ConfigType) -> None: validate_connection_slots(max_connections) # Check if hosted bluetooth is being used - if "esp32_hosted" in full_config: + if ESP32_HOSTED_DOMAIN 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 +612,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..11290a95f4 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 @@ -33,9 +38,9 @@ extern "C" bool btInUse() { return true; } // NOLINT(readability-identifier-nam namespace esphome::esp32_ble { -static const char *const TAG = "esp32_ble"; +ESPHOME_LOG_TAG(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(); @@ -331,19 +350,8 @@ bool ESP32BLE::ble_setup_() { } } else { const auto &app_name = App.get_name(); - size_t name_len = app_name.length(); - if (name_len > 20) { - if (App.is_name_add_mac_suffix_enabled()) { - // Keep first 13 chars and last 7 chars (MAC suffix), remove middle - memcpy(name_buffer, app_name.c_str(), 13); - memcpy(name_buffer + 13, app_name.c_str() + name_len - 7, 7); - } else { - memcpy(name_buffer, app_name.c_str(), 20); - } - name_buffer[20] = '\0'; - } else { - memcpy(name_buffer, app_name.c_str(), name_len + 1); // Include null terminator - } + truncate_name_to(name_buffer, sizeof(name_buffer), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); device_name = name_buffer; } @@ -393,7 +401,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 +424,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 +448,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/ble_advertising.cpp b/esphome/components/esp32_ble/ble_advertising.cpp index 68704e49e2..30d21ff9f4 100644 --- a/esphome/components/esp32_ble/ble_advertising.cpp +++ b/esphome/components/esp32_ble/ble_advertising.cpp @@ -11,7 +11,7 @@ namespace esphome::esp32_ble { -static const char *const TAG = "esp32_ble.advertising"; +ESPHOME_LOG_TAG(TAG, "esp32_ble.advertising"); BLEAdvertising::BLEAdvertising(uint32_t advertising_cycle_time) : advertising_cycle_time_(advertising_cycle_time) { this->advertising_data_.set_scan_rsp = false; diff --git a/esphome/components/esp32_ble_beacon/__init__.py b/esphome/components/esp32_ble_beacon/__init__.py index e9c44284e4..7498228f4c 100644 --- a/esphome/components/esp32_ble_beacon/__init__.py +++ b/esphome/components/esp32_ble_beacon/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["esp32_ble"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_ble_beacon" esp32_ble_beacon_ns = cg.esphome_ns.namespace("esp32_ble_beacon") ESP32BLEBeacon = esp32_ble_beacon_ns.class_("ESP32BLEBeacon", cg.Component) diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index ab728f9f6f..7481d2c612 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 @@ -18,7 +18,7 @@ namespace esphome::esp32_ble_beacon { -static const char *const TAG = "esp32_ble_beacon"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_beacon"); static const esp_ble_ibeacon_head_t IBEACON_COMMON_HEAD = { .flags = {0x02, 0x01, 0x06}, .length = 0x1A, .type = 0xFF, .company_id = {0x4C, 0x00}, .beacon_type = {0x02, 0x15}}; @@ -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_client/__init__.py b/esphome/components/esp32_ble_client/__init__.py index 55619f1fc0..dbb409b17e 100644 --- a/esphome/components/esp32_ble_client/__init__.py +++ b/esphome/components/esp32_ble_client/__init__.py @@ -4,6 +4,7 @@ from esphome.components import esp32_ble_tracker AUTO_LOAD = ["esp32_ble_tracker"] CODEOWNERS = ["@jesserockz", "@bdraco"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_ble_client" esp32_ble_client_ns = cg.esphome_ns.namespace("esp32_ble_client") BLEClientBase = esp32_ble_client_ns.class_( diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index e830702f11..fceca5f558 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -9,7 +9,7 @@ namespace esphome::esp32_ble_client { -static const char *const TAG = "esp32_ble_client"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_client"); BLECharacteristic::~BLECharacteristic() { for (auto &desc : this->descriptors) diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 88454f7bdb..dde47b0c5e 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -11,7 +11,7 @@ namespace esphome::esp32_ble_client { -static const char *const TAG = "esp32_ble_client"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_client"); // Connection parameters are shared with the other GATT client backends // (ble_device_base/ble_client_state.h) so the platforms cannot drift. @@ -150,6 +150,8 @@ void BLEClientBase::connect() { // A registration whose event never arrived must not block this connection's release. this->services_released_ = false; this->pending_notify_regs_ = 0; + // A CONNECT_EVT seen while idle can leave the id of a link this attempt does not own. + this->conn_id_ = UNSET_CONN_ID; // Enable loop for state processing this->enable_loop(); // Immediately transition to CONNECTING to prevent duplicate connection attempts @@ -350,7 +352,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (!this->check_addr(param->open.remote_bda)) return false; this->log_gattc_lifecycle_event_("OPEN"); - // conn_id was already set in ESP_GATTC_CONNECT_EVT this->service_count_ = 0; // ESP-IDF's BLE stack may send ESP_GATTC_OPEN_EVT after esp_ble_gattc_open() returns an @@ -362,19 +363,24 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ break; } - if (this->state() != espbt::ClientState::CONNECTING) { - // This should not happen but lets log it in case it does - // because it means we have a bad assumption about how the - // ESP BT stack works. + const bool open_failed = param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN; + // Bluedroid reports a failed open as DISCONNECT_EVT then a failing OPEN_EVT, with no CONNECT_EVT, + // so DISCONNECTING with no id is expected for a failure. Anything else means a bad assumption + // about how the ESP BT stack works, so log it. + const bool expected_failure = + open_failed && this->state() == espbt::ClientState::DISCONNECTING && this->conn_id_ == UNSET_CONN_ID; + if (this->state() != espbt::ClientState::CONNECTING && !expected_failure) { ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state()), param->open.status); } - if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { + if (open_failed) { this->log_gattc_warning_("Connection open", param->open.status); // Connection was never established so CLOSE_EVT may not follow this->set_idle_(); break; } + // An open on a link that is already up sends no CONNECT_EVT, so take the id here. + this->conn_id_ = param->open.conn_id; if (this->want_disconnect_) { // Disconnect was requested after connecting started, // but before the connection was established. Now that we have diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index 695f468c5b..40e9f81d23 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -8,7 +8,7 @@ namespace esphome::esp32_ble_client { -static const char *const TAG = "esp32_ble_client"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_client"); BLECharacteristic *BLEService::get_characteristic(espbt::ESPBTUUID uuid) { if (!this->parsed) diff --git a/esphome/components/esp32_ble_server/__init__.py b/esphome/components/esp32_ble_server/__init__.py index 9355d58859..d37b2f4020 100644 --- a/esphome/components/esp32_ble_server/__init__.py +++ b/esphome/components/esp32_ble_server/__init__.py @@ -1,11 +1,13 @@ import encodings +import struct +from typing import Any 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 +from esphome.components.esp32_ble import DOMAIN as ESP32_BLE_DOMAIN, BTLoggers, bt_uuid import esphome.config_validation as cv from esphome.config_validation import UNDEFINED from esphome.const import ( @@ -25,8 +27,10 @@ from esphome.const import ( __version__ as ESPHOME_VERSION, ) from esphome.core import CORE +from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigType, TemplateArgsType AUTO_LOAD = ["esp32_ble", "bytebuffer"] CODEOWNERS = ["@jesserockz", "@clydebarrow", "@Rapsssito"] @@ -111,8 +115,6 @@ BLECharacteristicNotifyAction = esp32_ble_server_automations_ns.class_( ) bytebuffer_ns = cg.esphome_ns.namespace("bytebuffer") Endianness_ns = bytebuffer_ns.namespace("Endian") -ByteBuffer_ns = bytebuffer_ns.namespace("ByteBuffer") -ByteBuffer = bytebuffer_ns.class_("ByteBuffer") PROPERTY_MAP = { @@ -141,6 +143,15 @@ class ValueType: return value +def float32(value: Any) -> float: + value = cv.float_(value) + try: + struct.pack(" None: max_clients = config[CONF_MAX_CLIENTS] if max_clients > 1: full_config = fv.full_config.get() - ble_config = full_config.get("esp32_ble", {}) + ble_config = full_config.get(ESP32_BLE_DOMAIN, {}) max_connections = ble_config.get( "max_connections", esp32_ble.DEFAULT_MAX_CONNECTIONS ) @@ -495,19 +506,48 @@ def parse_uuid(uuid): return ESPBTUUID_ns.from_raw(uuid) +_STRUCT_FORMATS = { + "uint8_t": "B", + "uint16_t": "H", + "uint32_t": "I", + "uint64_t": "Q", + "int8_t": "b", + "int16_t": "h", + "int32_t": "i", + "int64_t": "q", + "float": "f", + "double": "d", +} + + +def value_bytes(value_config: ConfigType) -> list[int]: + """The bytes of a constant value, as ByteBuffer::wrap packed them at runtime.""" + value = value_config[CONF_DATA] + if isinstance(value, str): + return list(value.encode(value_config[CONF_STRING_ENCODING])) + if isinstance(value, list): + return value + order = ">" if value_config[CONF_ENDIANNESS] == "BIG" else "<" + return list(struct.pack(order + _STRUCT_FORMATS[value_config[CONF_TYPE]], value)) + + async def parse_value(value_config, args): value = value_config[CONF_DATA] if isinstance(value, cv.Lambda): return await cg.templatable(value, args, cg.std_vector.template(cg.uint8)) + # An initializer list calls the set_value(std::initializer_list) overload + return cg.ArrayInitializer(*value_bytes(value_config)) - if isinstance(value, str): - value = list(value.encode(value_config[CONF_STRING_ENCODING])) - if isinstance(value, list): - # Generate initializer list {1, 2, 3} instead of std::vector({1, 2, 3}) - # This calls the set_value(std::initializer_list) overload - return cg.ArrayInitializer(*value) - val = cg.RawExpression(f"{value_config[CONF_TYPE]}({cg.safe_exp(value)})") - return ByteBuffer_ns.wrap(val, value_config[CONF_ENDIANNESS]) + +async def set_value_buffer( + var: MockObj, value_config: ConfigType, args: TemplateArgsType +) -> None: + value = value_config[CONF_DATA] + if not isinstance(value, cv.Lambda): + value = value_bytes(value_config) + await automation.templatable_bytes( + value, args, var.set_buffer_template, var.set_buffer_static, "ble_server_value" + ) def calculate_num_handles(service_config): @@ -662,8 +702,7 @@ async def to_code(config): async def ble_server_characteristic_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_SET_VALUE_ACTION") return var @@ -682,8 +721,7 @@ async def ble_server_characteristic_set_value(config, action_id, template_arg, a async def ble_server_descriptor_set_value(config, action_id, template_arg, args): paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - value = await parse_value(config[CONF_VALUE], args) - cg.add(var.set_buffer(value)) + await set_value_buffer(var, config[CONF_VALUE], args) cg.add_define("USE_ESP32_BLE_SERVER_DESCRIPTOR_SET_VALUE_ACTION") return var diff --git a/esphome/components/esp32_ble_server/ble_characteristic.cpp b/esphome/components/esp32_ble_server/ble_characteristic.cpp index 4d364b4655..1571a6077b 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_server/ble_characteristic.cpp @@ -9,7 +9,7 @@ namespace esphome::esp32_ble_server { -static const char *const TAG = "esp32_ble_server.characteristic"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_server.characteristic"); BLECharacteristic::~BLECharacteristic() { for (auto *descriptor : this->descriptors_) { @@ -30,6 +30,14 @@ BLECharacteristic::BLECharacteristic(const ESPBTUUID uuid, uint32_t properties) void BLECharacteristic::set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } +void BLECharacteristic::set_value(const uint8_t *data, size_t length) { + if (length == 0) { // An empty constant is a nullptr table + this->value_.clear(); + return; + } + this->value_.assign(data, data + length); +} + void BLECharacteristic::set_value(std::vector &&buffer) { this->value_ = std::move(buffer); } void BLECharacteristic::set_value(std::initializer_list data) { diff --git a/esphome/components/esp32_ble_server/ble_characteristic.h b/esphome/components/esp32_ble_server/ble_characteristic.h index b7a3fae1a5..905f5b7a0d 100644 --- a/esphome/components/esp32_ble_server/ble_characteristic.h +++ b/esphome/components/esp32_ble_server/ble_characteristic.h @@ -30,6 +30,7 @@ class BLECharacteristic final { ~BLECharacteristic(); void set_value(ByteBuffer buffer); + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(const std::string &buffer); diff --git a/esphome/components/esp32_ble_server/ble_descriptor.cpp b/esphome/components/esp32_ble_server/ble_descriptor.cpp index 3dcac3691c..7810dba9f5 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.cpp +++ b/esphome/components/esp32_ble_server/ble_descriptor.cpp @@ -9,7 +9,7 @@ namespace esphome::esp32_ble_server { -static const char *const TAG = "esp32_ble_server.descriptor"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_server.descriptor"); static RAMAllocator descriptor_allocator{}; // NOLINT @@ -49,17 +49,18 @@ void BLEDescriptor::do_create(BLECharacteristic *characteristic) { this->state_ = CREATING; } -void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value_impl_(buffer.data(), buffer.size()); } +void BLEDescriptor::set_value(std::vector &&buffer) { this->set_value(buffer.data(), buffer.size()); } -void BLEDescriptor::set_value(std::initializer_list data) { this->set_value_impl_(data.begin(), data.size()); } +void BLEDescriptor::set_value(std::initializer_list data) { this->set_value(data.begin(), data.size()); } -void BLEDescriptor::set_value_impl_(const uint8_t *data, size_t length) { +void BLEDescriptor::set_value(const uint8_t *data, size_t length) { if (length > this->value_.attr_max_len) { ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", length, this->value_.attr_max_len); return; } this->value_.attr_len = length; - memcpy(this->value_.attr_value, data, length); + if (length != 0) // An empty constant is a nullptr table + memcpy(this->value_.attr_value, data, length); } void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, diff --git a/esphome/components/esp32_ble_server/ble_descriptor.h b/esphome/components/esp32_ble_server/ble_descriptor.h index 5096d39f28..1ffd403434 100644 --- a/esphome/components/esp32_ble_server/ble_descriptor.h +++ b/esphome/components/esp32_ble_server/ble_descriptor.h @@ -26,6 +26,7 @@ class BLEDescriptor { void do_create(BLECharacteristic *characteristic); ESPBTUUID get_uuid() const { return this->uuid_; } + void set_value(const uint8_t *data, size_t length); void set_value(std::vector &&buffer); void set_value(std::initializer_list data); void set_value(ByteBuffer buffer) { this->set_value(buffer.get_data()); } @@ -42,8 +43,6 @@ class BLEDescriptor { } protected: - void set_value_impl_(const uint8_t *data, size_t length); - BLECharacteristic *characteristic_{nullptr}; ESPBTUUID uuid_; uint16_t handle_{0xFFFF}; diff --git a/esphome/components/esp32_ble_server/ble_server.cpp b/esphome/components/esp32_ble_server/ble_server.cpp index 45679b9b98..650a765b6a 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 @@ -18,7 +18,7 @@ namespace esphome::esp32_ble_server { -static const char *const TAG = "esp32_ble_server"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_server"); void BLEServer::setup() { if (this->parent_->is_failed()) { diff --git a/esphome/components/esp32_ble_server/ble_server_automations.h b/esphome/components/esp32_ble_server/ble_server_automations.h index e5463847fa..d90b2a28fd 100644 --- a/esphome/components/esp32_ble_server/ble_server_automations.h +++ b/esphome/components/esp32_ble_server/ble_server_automations.h @@ -16,6 +16,16 @@ namespace esphome::esp32_ble_server::esp32_ble_server_automations { using namespace esp32_ble; +/// Sets a characteristic or descriptor value from a constant flash table or a lambda's vector. +// Not TemplatableBytes::visit, which would copy the lambda's vector instead of moving it into the value. +template void apply_value(T *target, const V &value, Xs &&...x) { + if (value.is_static()) { + target->set_value(value.data(), value.size()); + } else { + target->set_value(value.value(x...)); + } +} + class BLETriggers { public: #ifdef USE_ESP32_BLE_SERVER_CHARACTERISTIC_ON_WRITE @@ -67,25 +77,23 @@ class BLECharacteristicSetValueActionManager { template class BLECharacteristicSetValueAction final : public Action { public: BLECharacteristicSetValueAction(BLECharacteristic *characteristic) : parent_(characteristic) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } + TEMPLATABLE_BYTES(buffer) void play(const Ts &...x) override { // If the listener is already set, do nothing if (BLECharacteristicSetValueActionManager::get_instance()->has_listener(this->parent_)) return; // Set initial value - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); // Set the listener for read events // ``mutable`` keeps by-copy captures non-const for triggers passing args by reference // (e.g. climate on_control's ClimateCall&). See #17142. this->parent_->on_read([this, x...](uint16_t id) mutable { // Set the value of the characteristic every time it is read - this->parent_->set_value(this->buffer_.value(x...)); + apply_value(this->parent_, this->buffer_, x...); }); // Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic BLECharacteristicSetValueActionManager::get_instance()->set_listener( - this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); }); + this->parent_, [this, x...]() mutable { apply_value(this->parent_, this->buffer_, x...); }); } protected: @@ -115,10 +123,8 @@ template class BLECharacteristicNotifyAction final : public Acti template class BLEDescriptorSetValueAction final : public Action { public: BLEDescriptorSetValueAction(BLEDescriptor *descriptor) : parent_(descriptor) {} - TEMPLATABLE_VALUE(std::vector, buffer) - void set_buffer(std::initializer_list buffer) { this->buffer_ = std::vector(buffer); } - void set_buffer(ByteBuffer buffer) { this->set_buffer(buffer.get_data()); } - void play(const Ts &...x) override { this->parent_->set_value(this->buffer_.value(x...)); } + TEMPLATABLE_BYTES(buffer) + void play(const Ts &...x) override { apply_value(this->parent_, this->buffer_, x...); } protected: BLEDescriptor *parent_; diff --git a/esphome/components/esp32_ble_server/ble_service.cpp b/esphome/components/esp32_ble_server/ble_service.cpp index ac8d7fa003..c8f4ecbf33 100644 --- a/esphome/components/esp32_ble_server/ble_service.cpp +++ b/esphome/components/esp32_ble_server/ble_service.cpp @@ -6,7 +6,7 @@ namespace esphome::esp32_ble_server { -static const char *const TAG = "esp32_ble_server.service"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_server.service"); BLEService::BLEService(ESPBTUUID uuid, uint16_t num_handles, uint8_t inst_id, bool advertise) : uuid_(uuid), num_handles_(num_handles), inst_id_(inst_id), advertise_(advertise) {} diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index 205acc55a5..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, @@ -277,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( @@ -381,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() @@ -436,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. @@ -452,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( diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 9b4eb2c3b3..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 { 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..cffa57a518 --- /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 { +ESPHOME_LOG_TAG(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..8d3148c2fc 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 @@ -32,7 +32,7 @@ namespace esphome::esp32_ble_tracker { -static const char *const TAG = "esp32_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "esp32_ble_tracker"); ESP32BLETracker *global_esp32_ble_tracker = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -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_camera/__init__.py b/esphome/components/esp32_camera/__init__.py index 3c41d22903..2d4e1ed7e9 100644 --- a/esphome/components/esp32_camera/__init__.py +++ b/esphome/components/esp32_camera/__init__.py @@ -33,6 +33,7 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["camera"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_camera" esp32_camera_ns = cg.esphome_ns.namespace("esp32_camera") ESP32Camera = esp32_camera_ns.class_("ESP32Camera", cg.PollingComponent, cg.EntityBase) diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 03fdbc4de7..ef29fc9b25 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -9,7 +9,7 @@ namespace esphome::esp32_camera { -static const char *const TAG = "esp32_camera"; +ESPHOME_LOG_TAG(TAG, "esp32_camera"); static constexpr size_t FRAMEBUFFER_TASK_STACK_SIZE = 1792; #if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE static constexpr uint32_t FRAME_LOG_INTERVAL_MS = 60000; diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index d54d5c6937..c063eb88be 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@ayufan"] AUTO_LOAD = ["camera"] DEPENDENCIES = ["network"] +DOMAIN = "esp32_camera_web_server" MULTI_CONF = True esp32_camera_web_server_ns = cg.esphome_ns.namespace("esp32_camera_web_server") diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index bee231d132..36c1cdfa45 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -16,7 +16,7 @@ namespace esphome::esp32_camera_web_server { static const uint32_t IMAGE_REQUEST_TIMEOUT = 5000; // How often streaming_handler_ reports its throughput. static const uint32_t STREAM_STATS_INTERVAL = 5000; -static const char *const TAG = "esp32_camera_web_server"; +ESPHOME_LOG_TAG(TAG, "esp32_camera_web_server"); #define PART_BOUNDARY "123456789000000000000987654321" #define CONTENT_TYPE "image/jpeg" diff --git a/esphome/components/esp32_can/__init__.py b/esphome/components/esp32_can/__init__.py index e69de29bb2..eb2b5b2aaa 100644 --- a/esphome/components/esp32_can/__init__.py +++ b/esphome/components/esp32_can/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_can" diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2272459fb8..2713bae241 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -129,7 +129,7 @@ async def to_code(config: ConfigType) -> None: # Also enable esp_driver_twai for future migration to new API include_builtin_idf_component("esp_driver_twai") var = cg.new_Pvariable(config[CONF_ID]) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) cg.add(var.set_rx(config[CONF_RX_PIN])) cg.add(var.set_tx(config[CONF_TX_PIN])) diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index e04405c63c..b98ba853e7 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -11,7 +11,7 @@ namespace esphome::esp32_can { -static const char *const TAG = "esp32_can"; +ESPHOME_LOG_TAG(TAG, "esp32_can"); static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config) { switch (bitrate) { @@ -84,6 +84,8 @@ bool ESP32Can::setup_internal() { twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT((gpio_num_t) this->tx_, (gpio_num_t) this->rx_, twai_mode); g_config.controller_id = next_twai_ctrl_num++; + g_config.alerts_enabled = TWAI_ALERT_ERR_PASS | TWAI_ALERT_ERR_ACTIVE | TWAI_ALERT_BUS_OFF | + TWAI_ALERT_BUS_RECOVERED | TWAI_ALERT_RX_QUEUE_FULL; if (this->tx_queue_len_.has_value()) { g_config.tx_queue_len = this->tx_queue_len_.value(); } @@ -116,6 +118,86 @@ bool ESP32Can::setup_internal() { return true; } +canbus::CanEventFlags ESP32Can::get_events() { + if (this->twai_handle_ == nullptr) { + return {}; // not set up yet, or setup failed + } + + uint32_t alerts; + esp_err_t err = twai_read_alerts_v2(this->twai_handle_, &alerts, 0); + if (err != ESP_OK) { + if (err != ESP_ERR_TIMEOUT) { + ESP_LOGD(TAG, "failed to get CAN events"); + } + alerts = 0; + } + + uint8_t events = 0; + if (alerts & TWAI_ALERT_ERR_PASS) { + events |= canbus::CAN_EVENT_PASSIVE; + } + if (alerts & TWAI_ALERT_ERR_ACTIVE) { + events |= canbus::CAN_EVENT_ACTIVE; + } + if (alerts & TWAI_ALERT_BUS_OFF) { + events |= canbus::CAN_EVENT_BUS_OFF; + // Start recovery at once, as the MCP2515 does in hardware + this->recovery_pending_ = true; + } + if (alerts & TWAI_ALERT_BUS_RECOVERED) { + // Recovery leaves the controller stopped + this->restart_pending_ = true; + } + if (this->recovery_pending_ || this->restart_pending_) { + this->handle_bus_off_recovery_(); + } + if (alerts & TWAI_ALERT_RX_QUEUE_FULL) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + } + return static_cast(events); +} + +void ESP32Can::handle_bus_off_recovery_() { + // These only fail with an invalid handle or outside the expected state; retry on the next pass + if (this->recovery_pending_) { + if (twai_initiate_recovery_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Recovery after bus off failed")); + return; + } + this->recovery_pending_ = false; + } + if (this->restart_pending_) { + if (twai_start_v2(this->twai_handle_) != ESP_OK) { + this->status_set_error(LOG_STR("Restart after bus off failed")); + return; + } + this->restart_pending_ = false; + } + this->status_clear_error(); +} + +canbus::CanStatus ESP32Can::get_status() { + canbus::CanStatus status = {}; + + twai_status_info_t twai_status; + + if (this->twai_handle_ != nullptr && twai_get_status_info_v2(this->twai_handle_, &twai_status) == ESP_OK) { + status.bus_off = (twai_status.state == TWAI_STATE_BUS_OFF) || (twai_status.state == TWAI_STATE_RECOVERING); + status.rx_error_counter = twai_status.rx_error_counter; + status.tx_error_counter = twai_status.tx_error_counter; + + status.tx_failed_count = twai_status.tx_failed_count; + status.rx_missed_count = twai_status.rx_missed_count; + status.rx_overrun_count = twai_status.rx_overrun_count; + status.arb_lost_count = twai_status.arb_lost_count; + status.bus_error_count = twai_status.bus_error_count; + } else { + ESP_LOGD(TAG, "failed to get CAN status"); + } + + return status; +} + canbus::Error ESP32Can::send_message(struct canbus::CanFrame *frame) { // In listen-only mode, we cannot transmit if (this->mode_ == CAN_MODE_LISTEN_ONLY) { diff --git a/esphome/components/esp32_can/esp32_can.h b/esphome/components/esp32_can/esp32_can.h index f224e10be3..f9549bea34 100644 --- a/esphome/components/esp32_can/esp32_can.h +++ b/esphome/components/esp32_can/esp32_can.h @@ -24,16 +24,21 @@ class ESP32Can final : public canbus::Canbus { void set_tx_enqueue_timeout_ms(uint32_t tx_enqueue_timeout_ms) { this->tx_enqueue_timeout_ticks_ = pdMS_TO_TICKS(tx_enqueue_timeout_ms); } + canbus::CanStatus get_status() override; ESP32Can(){}; protected: bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error send_message(struct canbus::CanFrame *frame) override; canbus::Error read_message(struct canbus::CanFrame *frame) override; + void handle_bus_off_recovery_(); int rx_{-1}; int tx_{-1}; CanMode mode_{CAN_MODE_NORMAL}; + bool recovery_pending_{false}; + bool restart_pending_{false}; TickType_t tx_enqueue_timeout_ticks_{}; optional tx_queue_len_{}; optional rx_queue_len_{}; diff --git a/esphome/components/esp32_dac/__init__.py b/esphome/components/esp32_dac/__init__.py index e69de29bb2..6aa72c6456 100644 --- a/esphome/components/esp32_dac/__init__.py +++ b/esphome/components/esp32_dac/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_dac" diff --git a/esphome/components/esp32_dac/esp32_dac.cpp b/esphome/components/esp32_dac/esp32_dac.cpp index 54b89c46ad..3517941de1 100644 --- a/esphome/components/esp32_dac/esp32_dac.cpp +++ b/esphome/components/esp32_dac/esp32_dac.cpp @@ -12,7 +12,7 @@ static constexpr uint8_t DAC0_PIN = 17; static constexpr uint8_t DAC0_PIN = 25; #endif -static const char *const TAG = "esp32_dac"; +ESPHOME_LOG_TAG(TAG, "esp32_dac"); void ESP32DAC::setup() { this->pin_->setup(); diff --git a/esphome/components/esp32_hall/__init__.py b/esphome/components/esp32_hall/__init__.py index e69de29bb2..a8e97842ae 100644 --- a/esphome/components/esp32_hall/__init__.py +++ b/esphome/components/esp32_hall/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_hall" diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index ad01e130eb..f84f9c6464 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,22 +8,33 @@ 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"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_hosted" # 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 +157,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 +195,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 +264,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 +347,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 +413,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,18 +425,64 @@ 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.13") diff --git a/esphome/components/esp32_hosted/esp_now_hosted.cpp b/esphome/components/esp32_hosted/esp_now_hosted.cpp index ad29b208fe..03b03fd983 100644 --- a/esphome/components/esp32_hosted/esp_now_hosted.cpp +++ b/esphome/components/esp32_hosted/esp_now_hosted.cpp @@ -45,7 +45,7 @@ extern "C" { namespace { -const char *const TAG = "esp_now_hosted"; +ESPHOME_LOG_TAG(TAG, "esp_now_hosted"); // One outstanding request at a time. ESPHome drives esp_now_* from the main // loop; the matching response and the async RECV/SEND events all arrive on the diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index d9b375dd20..7b9056bc80 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -22,10 +22,12 @@ extern "C" { namespace esphome::esp32_hosted { -static const char *const TAG = "esp32_hosted.update"; +ESPHOME_LOG_TAG(TAG, "esp32_hosted.update"); // Older coprocessor firmware versions have a 1500-byte limit per RPC call constexpr size_t CHUNK_SIZE = 1500; +// OTA begin blocks while the coprocessor erases its partition +constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000; #ifdef USE_ESP32_HOSTED_HTTP_UPDATE // Interval/timeout IDs (uint32_t to avoid string comparison) @@ -336,7 +338,11 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() { ESP_LOGI(TAG, "Firmware size: %zu bytes", total_size); // Begin OTA on coprocessor - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + esp_err_t err; + { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + err = esp_hosted_slave_ota_begin(); // NOLINT + } if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); container->end(); @@ -425,7 +431,11 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() { ESP_LOGI(TAG, "Starting OTA update (%zu bytes)", this->firmware_size_); - esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT + esp_err_t err; + { + watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS); + err = esp_hosted_slave_ota_begin(); // NOLINT + } if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); this->status_set_error(LOG_STR("Failed to begin OTA")); @@ -472,8 +482,6 @@ void Esp32HostedUpdate::perform(bool force) { this->update_info_.has_progress = false; this->publish_state(); - watchdog::WatchdogManager watchdog(60000); - #ifdef USE_ESP32_HOSTED_HTTP_UPDATE if (!this->stream_firmware_to_coprocessor_()) #else diff --git a/esphome/components/esp32_power_management/__init__.py b/esphome/components/esp32_power_management/__init__.py new file mode 100644 index 0000000000..76622032cd --- /dev/null +++ b/esphome/components/esp32_power_management/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_power_management" diff --git a/esphome/components/esp32_power_management/esp32_power_management.cpp b/esphome/components/esp32_power_management/esp32_power_management.cpp new file mode 100644 index 0000000000..41ba352e5a --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.cpp @@ -0,0 +1,75 @@ +#include "esp32_power_management.h" +#ifdef USE_ESP32 +#include "esphome/core/log.h" + +#include "esp_private/esp_clk.h" +#include "soc/rtc.h" + +namespace esphome::esp32_power_management { + +ESPHOME_LOG_TAG(TAG, "esp32_power_management"); + +void ESP32PowerManagement::setup() { + if (this->max_freq_mhz_ == 0) + this->max_freq_mhz_ = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ; + if (this->min_freq_mhz_ == 0) + this->min_freq_mhz_ = esp_clk_xtal_freq() / MHZ; + // A low boot frequency (ESP32-H2 at 16 MHz) can sit below the crystal frequency. + if (this->min_freq_mhz_ > this->max_freq_mhz_) + this->min_freq_mhz_ = this->max_freq_mhz_; + + esp_pm_config_t pm_config = { + .max_freq_mhz = this->max_freq_mhz_, + .min_freq_mhz = this->min_freq_mhz_, +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + .light_sleep_enable = true, +#endif + }; + esp_err_t rc = esp_pm_configure(&pm_config); + if (rc != ESP_OK) { + // The usual cause is a minimum frequency that does not divide this board's crystal + ESP_LOGE(TAG, "esp_pm_configure failed: %s (crystal %u MHz)", esp_err_to_name(rc), + static_cast(esp_clk_xtal_freq() / MHZ)); + this->mark_failed(); + } +} + +void ESP32PowerManagement::dump_config() { + ESP_LOGCONFIG(TAG, + "Power Management:\n" + " Max Frequency: %uMHZ\n" + " Min Frequency: %uMHZ", + this->max_freq_mhz_, this->min_freq_mhz_); +#if SOC_CLK_XTAL32K_SUPPORTED + ESP_LOGCONFIG(TAG, " 32k XTAL: %s", + rtc_clk_slow_src_get() == SOC_RTC_SLOW_CLK_SRC_XTAL32K ? LOG_STR_LITERAL("in use") + : LOG_STR_LITERAL("not in use")); +#endif +#if CONFIG_FREERTOS_USE_TICKLESS_IDLE + ESP_LOGCONFIG(TAG, " Light Sleep Enabled"); +#if CONFIG_ESP_SLEEP_POWER_DOWN_FLASH + ESP_LOGCONFIG(TAG, " PM Flash Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP + ESP_LOGCONFIG(TAG, " PM Peripheral Power Down in Light Sleep Enabled"); +#endif +#if CONFIG_IEEE802154_SLEEP_ENABLE + ESP_LOGCONFIG(TAG, " ieee802154 Sleep Enabled"); +#endif +#if CONFIG_PM_PROFILING + ESP_LOGCONFIG(TAG, " PM Profiling Enabled"); +#endif +#if CONFIG_PM_TRACE + ESP_LOGCONFIG(TAG, " PM Trace Enabled"); +#endif +#if defined(CONFIG_LWIP_IPV6) && !defined(CONFIG_LWIP_ND6) + ESP_LOGCONFIG(TAG, " IPv6 Neighbor Discovery Disabled (CONFIG_LWIP_ND6)"); +#endif +#if CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION + ESP_LOGCONFIG(TAG, " USB Serial/JTAG Light Sleep Prevention Enabled"); +#endif +#endif +} + +} // namespace esphome::esp32_power_management +#endif diff --git a/esphome/components/esp32_power_management/esp32_power_management.h b/esphome/components/esp32_power_management/esp32_power_management.h new file mode 100644 index 0000000000..e6194e7681 --- /dev/null +++ b/esphome/components/esp32_power_management/esp32_power_management.h @@ -0,0 +1,28 @@ +#pragma once + +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/power_management/power_management.h" +// Lets a YAML lambda call esp_pm_dump_locks(). +#include "esp_pm.h" + +namespace esphome::esp32_power_management { + +class ESP32PowerManagement : public power_management::PowerManagementComponent { + public: + float get_setup_priority() const override { return setup_priority::POWER; } + void setup() override; + void dump_config() override; + void set_max_freq_mhz(uint16_t max_freq_mhz) { this->max_freq_mhz_ = max_freq_mhz; } + void set_min_freq_mhz(uint16_t min_freq_mhz) { this->min_freq_mhz_ = min_freq_mhz; } + + protected: + // Zero means the build default; setup() replaces it with the value applied. + uint16_t max_freq_mhz_{0}; + uint16_t min_freq_mhz_{0}; +}; + +} // namespace esphome::esp32_power_management + +#endif diff --git a/esphome/components/esp32_power_management/power_management.py b/esphome/components/esp32_power_management/power_management.py new file mode 100644 index 0000000000..e9b6d6462c --- /dev/null +++ b/esphome/components/esp32_power_management/power_management.py @@ -0,0 +1,254 @@ +import logging + +import esphome.codegen as cg +from esphome.components import esp32, power_management +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, + add_idf_sdkconfig_option, + get_esp32_variant, +) +from esphome.components.openthread.const import CONF_POLL_PERIOD +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN +from esphome.components.zigbee import DOMAIN as ZIGBEE_DOMAIN +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_OPENTHREAD, CONF_PLATFORM +from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@rwrozelle"] +DEPENDENCIES = ["esp32"] + +CONF_ENABLE_LIGHT_SLEEP = "enable_light_sleep" +CONF_IDLE_TIME_BEFORE_SLEEP = "idle_time_before_sleep" +CONF_MAX_FREQUENCY = "max_frequency" +CONF_MIN_FREQUENCY = "min_frequency" +CONF_POWER_DOWN_FLASH = "power_down_flash" +CONF_POWER_DOWN_PERIPHERALS = "power_down_peripherals" +CONF_PROFILING = "profiling" +CONF_TRACE = "trace" + +# Variants with a TOP power domain, which can power down peripherals in light sleep. +_TOP_PD_VARIANTS = [ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32H21, + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S31, +] + +# Crystal frequencies each variant can be built for (ESP-IDF's SOC_XTAL_FREQ_* values). +# Below the PLL frequencies the CPU runs from the crystal through an integer divider. +_XTAL_MHZ = { + VARIANT_ESP32: (24, 26, 40), + VARIANT_ESP32C2: (26, 32, 40), + VARIANT_ESP32C3: (32, 40), + VARIANT_ESP32C5: (40, 48), + VARIANT_ESP32C6: (40,), + VARIANT_ESP32C61: (40,), + VARIANT_ESP32H2: (32,), + VARIANT_ESP32H21: (32,), + VARIANT_ESP32H4: (32,), + VARIANT_ESP32P4: (40,), + VARIANT_ESP32S2: (40,), + VARIANT_ESP32S3: (32, 40), + VARIANT_ESP32S31: (40,), +} +# esp_pm_configure() keeps the 1 MHz reference clock reachable: its divider must be +# at least 10 on the ESP32 and 2 on every other variant. +_MIN_FREQUENCY_FLOOR_MHZ = {VARIANT_ESP32: 10} + +esp32_power_management_ns = cg.esphome_ns.namespace("esp32_power_management") +PowerManagement = esp32_power_management_ns.class_( + "ESP32PowerManagement", power_management.PowerManagementComponent +) + + +def _mhz(value: object) -> int: + """Validate a frequency and keep it as whole megahertz.""" + return int(cv.frequency(value)) // 1_000_000 + + +def valid_min_frequencies(variant: str) -> list[int]: + """The minimum CPU frequencies esp_pm_configure() accepts on a variant, in MHz. + + A value must divide one of the variant's crystal frequencies, stay above the + reference clock floor, or be one of the PLL frequencies. + """ + floor = _MIN_FREQUENCY_FLOOR_MHZ.get(variant, 2) + mhz = { + xtal // divider + for xtal in _XTAL_MHZ[variant] + for divider in range(1, xtal // floor + 1) + if xtal % divider == 0 + } + mhz.update(int(freq[:-3]) for freq in esp32.CPU_FREQUENCIES[variant]) + return sorted(mhz) + + +def _validate(config: ConfigType) -> ConfigType: + variant = get_esp32_variant() + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + valid = esp32.CPU_FREQUENCIES[variant] + if f"{max_mhz}MHZ" not in valid: + raise cv.Invalid( + f"{max_mhz}MHZ is not a valid CPU frequency for {variant}. " + f"Valid options are: {', '.join(valid)}", + path=[CONF_MAX_FREQUENCY], + ) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and ( + min_mhz not in (valid_min := valid_min_frequencies(variant)) + ): + raise cv.Invalid( + f"{min_mhz}MHZ is not a valid minimum frequency for {variant}; it must " + f"divide the crystal frequency. Valid options are: " + f"{', '.join(f'{mhz}MHZ' for mhz in valid_min)}", + path=[CONF_MIN_FREQUENCY], + ) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + for key in (CONF_POWER_DOWN_PERIPHERALS, CONF_POWER_DOWN_FLASH): + if config.get(key): + raise cv.Invalid( + f"{key} requires {CONF_ENABLE_LIGHT_SLEEP}: true", path=[key] + ) + config[CONF_POWER_DOWN_PERIPHERALS] = False + elif CONF_POWER_DOWN_PERIPHERALS not in config: + config[CONF_POWER_DOWN_PERIPHERALS] = variant in _TOP_PD_VARIANTS + elif config[CONF_POWER_DOWN_PERIPHERALS] and variant not in _TOP_PD_VARIANTS: + raise cv.Invalid( + f"{CONF_POWER_DOWN_PERIPHERALS} is only available on " + f"{', '.join(_TOP_PD_VARIANTS)}", + path=[CONF_POWER_DOWN_PERIPHERALS], + ) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(PowerManagement), + cv.Optional(CONF_MAX_FREQUENCY): _mhz, + cv.Optional(CONF_MIN_FREQUENCY): _mhz, + cv.Optional(CONF_ENABLE_LIGHT_SLEEP, default=False): cv.boolean, + cv.Optional(CONF_IDLE_TIME_BEFORE_SLEEP, default=3): cv.int_range( + min=2, max=4294967295 + ), + cv.Optional(CONF_POWER_DOWN_PERIPHERALS): cv.boolean, + cv.Optional(CONF_POWER_DOWN_FLASH, default=False): cv.boolean, + cv.Optional(CONF_PROFILING, default=False): cv.boolean, + # Repurposes GPIOs as trace outputs, so keep it out of the visual editor. + cv.Optional( + CONF_TRACE, default=False, visibility=cv.Visibility.YAML_ONLY + ): cv.boolean, + } + ).extend(cv.COMPONENT_SCHEMA), + _validate, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await power_management.register_power_management(var, config) + add_idf_sdkconfig_option("CONFIG_PM_ENABLE", True) + + if (max_mhz := config.get(CONF_MAX_FREQUENCY)) is not None: + cg.add(var.set_max_freq_mhz(max_mhz)) + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None: + cg.add(var.set_min_freq_mhz(min_mhz)) + if config[CONF_PROFILING]: + add_idf_sdkconfig_option("CONFIG_PM_PROFILING", True) + if config[CONF_TRACE]: + add_idf_sdkconfig_option("CONFIG_PM_TRACE", True) + if not config[CONF_ENABLE_LIGHT_SLEEP]: + return + + # Automatic light sleep whenever no task is runnable. + add_idf_sdkconfig_option("CONFIG_FREERTOS_USE_TICKLESS_IDLE", True) + add_idf_sdkconfig_option("CONFIG_ESP_PHY_MAC_BB_PD", True) + # Kconfig's own "depends on" no-ops this where it doesn't apply. + add_idf_sdkconfig_option("CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION", True) + add_idf_sdkconfig_option( + "CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP", config[CONF_IDLE_TIME_BEFORE_SLEEP] + ) + if config[CONF_POWER_DOWN_PERIPHERALS]: + add_idf_sdkconfig_option("CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP", True) + if config[CONF_POWER_DOWN_FLASH]: + add_idf_sdkconfig_option("CONFIG_ESP_SLEEP_POWER_DOWN_FLASH", True) + + ot_conf = CORE.config.get(CONF_OPENTHREAD) + # A polling (sleepy) Thread end device cannot answer neighbour solicitations. + if ( + ot_conf is not None + and (poll_period := ot_conf.get(CONF_POLL_PERIOD)) is not None + and poll_period.total_milliseconds > 0 + ): + add_idf_sdkconfig_option("CONFIG_LWIP_ND6", False) + if ot_conf is not None or ZIGBEE_DOMAIN in CORE.config: + add_idf_sdkconfig_option("CONFIG_IEEE802154_SLEEP_ENABLE", True) + + +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + entries = full_config.get(power_management.DOMAIN, []) + if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in entries) > 1: + raise cv.Invalid(f"Only one {DOMAIN} instance is allowed") + boot_freq = full_config[esp32.DOMAIN][esp32.CONF_CPU_FREQUENCY] + boot_mhz = int(boot_freq[:-3]) + max_mhz = config.get(CONF_MAX_FREQUENCY) + # Without max_frequency the boot frequency is the ceiling esp_pm_configure() gets. + if (min_mhz := config.get(CONF_MIN_FREQUENCY)) is not None and min_mhz > ( + max_mhz if max_mhz is not None else boot_mhz + ): + raise cv.Invalid( + f"{CONF_MIN_FREQUENCY} must not be greater than the maximum frequency", + path=[CONF_MIN_FREQUENCY], + ) + if max_mhz is not None: + if boot_mhz > max_mhz: + _LOGGER.info( + "esp32.%s (%s) is higher than %s (%dMHZ); the CPU will be " + "downclocked to %dMHZ as soon as power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + elif boot_mhz < max_mhz: + _LOGGER.warning( + "esp32.%s (%s) is lower than %s (%dMHZ); the CPU may run as " + "high as %dMHZ under load once power management is set up", + esp32.CONF_CPU_FREQUENCY, + boot_freq, + CONF_MAX_FREQUENCY, + max_mhz, + max_mhz, + ) + if config[CONF_POWER_DOWN_FLASH] and PSRAM_DOMAIN in full_config: + raise cv.Invalid( + f"{CONF_POWER_DOWN_FLASH} is not allowed when the device has PSRAM", + path=[CONF_POWER_DOWN_FLASH], + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index a213a78778..9d3562ed7b 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -7,6 +7,7 @@ from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] +DOMAIN = "esp32_rmt" VARIANTS_NO_RMT = {esp32.VARIANT_ESP32C2, esp32.VARIANT_ESP32C61} diff --git a/esphome/components/esp32_rmt_led_strip/__init__.py b/esphome/components/esp32_rmt_led_strip/__init__.py index e69de29bb2..a33110a7d3 100644 --- a/esphome/components/esp32_rmt_led_strip/__init__.py +++ b/esphome/components/esp32_rmt_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp32_rmt_led_strip" diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.cpp b/esphome/components/esp32_rmt_led_strip/led_strip.cpp index 7cac1dfb41..420ff64ddc 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.cpp +++ b/esphome/components/esp32_rmt_led_strip/led_strip.cpp @@ -11,7 +11,7 @@ namespace esphome::esp32_rmt_led_strip { -static const char *const TAG = "esp32_rmt_led_strip"; +ESPHOME_LOG_TAG(TAG, "esp32_rmt_led_strip"); static const size_t RMT_SYMBOLS_PER_BYTE = 8; diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index ede6beb9b6..bdd16bc2ef 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -31,6 +31,7 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["binary_sensor"] DEPENDENCIES = ["esp32"] +DOMAIN = "esp32_touch" CONF_DEBOUNCE_COUNT = "debounce_count" CONF_DENOISE_GRADE = "denoise_grade" diff --git a/esphome/components/esp32_touch/esp32_touch.cpp b/esphome/components/esp32_touch/esp32_touch.cpp index 54bbbe52ed..ed7d0bd7cb 100644 --- a/esphome/components/esp32_touch/esp32_touch.cpp +++ b/esphome/components/esp32_touch/esp32_touch.cpp @@ -13,7 +13,7 @@ template static const char *lookup_str(const char *const (&table)[N], return (index < N) ? table[index] : "UNKNOWN"; } -static const char *const TAG = "esp32_touch"; +ESPHOME_LOG_TAG(TAG, "esp32_touch"); static constexpr uint32_t SETUP_MODE_LOG_INTERVAL_MS = 250; static constexpr uint32_t INITIAL_STATE_DELAY_MS = 1500; diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 56509b9fab..d95cbd239b 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, @@ -43,12 +46,14 @@ from .const import ( 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, + THROW_STUBS_HEADER, enable_serial, enable_serial1, esp8266_ns, @@ -66,6 +71,7 @@ CONF_ENABLE_SCANF_FLOAT = "enable_scanf_float" _SCANF_FLOAT_RE = re.compile(r"scanf\s*\([^;]*?%[*\d.]*[hlL]*[feEgGaAF]") CODEOWNERS = ["@esphome/core"] +DOMAIN = "esp8266" _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["preferences"] IS_TARGET_PLATFORM = True @@ -105,6 +111,87 @@ 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) + + +# Known boards keyed by spelling with case and "-" / "_" folded away +_FOLDED_BOARDS = {board.lower().replace("-", "_"): board for board in BOARDS} + + +def _resolve_board(config: ConfigType) -> ConfigType: + """Fix a misspelled board, else build an unknown one with PlatformIO.""" + board = config[CONF_BOARD] + if board in BOARDS: + return config + if canonical := _FOLDED_BOARDS.get(board.lower().replace("-", "_")): + _LOGGER.warning( + "Board '%s' is not a known ESP8266 board; using '%s'", board, canonical + ) + return {**config, CONF_BOARD: canonical} + if CORE.toolchain is None and CONF_TOOLCHAIN not in config: + return {**config, CONF_TOOLCHAIN: Toolchain.PLATFORMIO} + return config + + +def _warn_platformio_toolchain(config: ConfigType) -> ConfigType: + # Remove before 2027.4.0 + if not CORE.using_toolchain_platformio: + return config + if config[CONF_BOARD] not in BOARDS: + _LOGGER.warning( + "Board '%s' is not supported by the native 'arduino' toolchain, so it " + "builds with the deprecated 'platformio' toolchain, which will be " + "removed in ESPHome 2027.4.0", + config[CONF_BOARD], + ) + else: + _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. @@ -129,16 +216,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 @@ -149,7 +247,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 @@ -196,7 +295,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 @@ -219,6 +318,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( { @@ -251,11 +354,15 @@ 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_board, + _resolve_toolchain, + _warn_platformio_toolchain, + _validate_native_toolchain, set_core_data, ) @@ -263,8 +370,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 @@ -279,9 +386,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" ) @@ -295,12 +403,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]) @@ -318,29 +427,32 @@ async def to_code(config: ConfigType) -> None: # The native generator reads the same decision (KEY_SCANF_FLOAT) CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float) - - 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) + 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]}"], - ) + # .rodata is linked into RAM on ESP8266; keep switch lookup tables (CSWTCH) out of it + cg.add_build_flag("-fno-tree-switch-conversion") + 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 @@ -369,11 +481,9 @@ async def to_code(config: ConfigType) -> None: # Force-include inline std::__throw_* overrides so GCC dead-strips the unused # libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM. - # See throw_stubs.h for details. Must be prepended before , so this - # uses build_src_flags with -include. - cg.add_platformio_option( - "build_src_flags", "-include esphome/components/esp8266/throw_stubs.h" - ) + # See throw_stubs.h. The native build generator reads this option, also + # passes it to the core and libraries, and drops it with exceptions on. + cg.add_platformio_option("build_src_flags", f"-include {THROW_STUBS_HEADER}") # In testing mode, fake larger memory to allow linking grouped component tests # Real ESP8266 hardware only has 32KB IRAM and ~80KB RAM, but for CI testing @@ -400,6 +510,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] @@ -408,7 +520,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]) ) @@ -449,8 +561,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", @@ -507,22 +635,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) @@ -592,6 +752,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 ac2fe0aa3b..fb50b6a5ca 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -17,8 +17,12 @@ KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" # Set for the native (non-PlatformIO) toolchain's build generator KEY_SCANF_FLOAT = "scanf_float" +# Force-included std::__throw_* overrides, relative to the src dir +THROW_STUBS_HEADER = "esphome/components/esp8266/throw_stubs.h" # 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") diff --git a/esphome/components/esp8266/crash_handler.cpp b/esphome/components/esp8266/crash_handler.cpp index dc79043f21..1ba96eaebb 100644 --- a/esphome/components/esp8266/crash_handler.cpp +++ b/esphome/components/esp8266/crash_handler.cpp @@ -82,7 +82,7 @@ static_assert(sizeof(RtcCrashData) == 72, "RtcCrashData must fit in 18 RTC block namespace esphome::esp8266 { -static const char *const TAG = "esp8266"; +ESPHOME_LOG_TAG(TAG, "esp8266"); static inline bool is_crash_reason(uint32_t reason) { return reason == REASON_WDT_RST || reason == REASON_EXCEPTION_RST || reason == REASON_SOFT_WDT_RST; diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index a85f054dfe..1d97b2c1e5 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -1,13 +1,16 @@ #ifdef USE_ESP8266 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::esp8266 { -static const char *const TAG = "esp8266"; +ESPHOME_LOG_TAG(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; } @@ -72,7 +75,7 @@ ISRInternalGPIOPin ESP8266GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ESP8266GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ESP8266GPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { uint8_t arduino_mode = 0; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index 57ef06106a..84968d9752 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -2,12 +2,12 @@ #ifdef USE_ESP8266 -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" #include namespace esphome::esp8266 { -class ESP8266GPIOPin final : public InternalGPIOPin { +class ESP8266GPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -18,14 +18,18 @@ class ESP8266GPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/esp8266/hal.h b/esphome/components/esp8266/hal.h index f3b33da692..1a54065541 100644 --- a/esphome/components/esp8266/hal.h +++ b/esphome/components/esp8266/hal.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -40,6 +41,9 @@ void delay_microseconds_safe(uint32_t us); /// which is ISR-safe) so this helper is unused on this platform. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +/// SDK SYS callbacks and interrupt handlers cannot yield, so they are not the loop context. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return can_yield(); } + __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } void delay(uint32_t ms); diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index d954ae4a0f..b731187f9c 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -14,7 +14,7 @@ extern "C" { namespace esphome::esp8266 { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; diff --git a/esphome/components/esp8266/throw_stubs.h b/esphome/components/esp8266/throw_stubs.h index a650935a5e..c18814d4d8 100644 --- a/esphome/components/esp8266/throw_stubs.h +++ b/esphome/components/esp8266/throw_stubs.h @@ -15,7 +15,8 @@ * unused, dead-strip the load, and drop the string entirely -- no LTO needed. * Behavior is identical to today: a bare abort() (the message was never * printed). This header MUST be force-included before , so it is - * wired up via build_src_flags "-include ..." in this component's __init__.py. + * wired up via build_src_flags "-include ..." in this component's __init__.py; + * the native toolchain also force-includes it into the core and libraries. * * Note: this defines functions in namespace std (technically UB). It is safe * here because the definitions match the existing abort() behavior exactly. diff --git a/esphome/components/esp8266_pwm/__init__.py b/esphome/components/esp8266_pwm/__init__.py index e69de29bb2..741b06bb8b 100644 --- a/esphome/components/esp8266_pwm/__init__.py +++ b/esphome/components/esp8266_pwm/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esp8266_pwm" diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.cpp b/esphome/components/esp8266_pwm/esp8266_pwm.cpp index b5b3d5073a..c01a69ce10 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.cpp +++ b/esphome/components/esp8266_pwm/esp8266_pwm.cpp @@ -10,7 +10,7 @@ namespace esphome::esp8266_pwm { -static const char *const TAG = "esp8266_pwm"; +ESPHOME_LOG_TAG(TAG, "esp8266_pwm"); void ESP8266PWM::setup() { this->pin_->setup(); 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.cpp b/esphome/components/esp_ldo/esp_ldo.cpp index b3c9a59865..e978dd12b0 100644 --- a/esphome/components/esp_ldo/esp_ldo.cpp +++ b/esphome/components/esp_ldo/esp_ldo.cpp @@ -5,7 +5,7 @@ namespace esphome::esp_ldo { -static const char *const TAG = "esp_ldo"; +ESPHOME_LOG_TAG(TAG, "esp_ldo"); void EspLdo::setup() { esp_ldo_channel_config_t config{}; config.chan_id = this->channel_; 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..4f10db996c --- /dev/null +++ b/esphome/components/espectre/__init__.py @@ -0,0 +1,186 @@ +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, +) +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN +import esphome.config_validation as cv +from esphome.const import CONF_ID +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[WIFI_DOMAIN].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..c758a3b246 --- /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 { + +ESPHOME_LOG_TAG(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..bb6024a233 --- /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 { + +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/esphome/__init__.py new file mode 100644 index 0000000000..8bcc59dd2d --- /dev/null +++ b/esphome/components/esphome/__init__.py @@ -0,0 +1 @@ +DOMAIN = "esphome" diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index bace13c17f..7a0d03df52 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -2,11 +2,17 @@ import logging import esphome.codegen as cg from esphome.components.noise import ( - encryption_schema, + ENCRYPTION_SCHEMA, new_psk_progmem, static_encryption_key, + validate_encryption_key, +) +from esphome.components.ota import ( + BASE_OTA_SCHEMA, + DOMAIN as OTA_DOMAIN, + OTAComponent, + ota_to_code, ) -from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code from esphome.config_helpers import filter_source_files_from_defines, merge_config import esphome.config_validation as cv from esphome.const import ( @@ -27,9 +33,12 @@ 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, CONF_OLD_KEY import esphome.final_validate as fv from esphome.types import ConfigType +from .. import DOMAIN + CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access" _LOGGER = logging.getLogger(__name__) @@ -55,12 +64,12 @@ ESPHomeOTAComponent = esphome.class_("ESPHomeOTAComponent", OTAComponent) def ota_esphome_final_validate(config: ConfigType) -> None: full_conf = fv.full_config.get() - full_ota_conf = full_conf[CONF_OTA] + full_ota_conf = full_conf[OTA_DOMAIN] new_ota_conf = [] merged_ota_esphome_configs_by_port = {} ports_with_merged_configs = [] for ota_conf in full_ota_conf: - if ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME: + if ota_conf.get(CONF_PLATFORM) == DOMAIN: if ( conf_port := ota_conf.get(CONF_PORT) ) not in merged_ota_esphome_configs_by_port: @@ -91,16 +100,18 @@ def ota_esphome_final_validate(config: ConfigType) -> None: # Encryption blocks conflict only when both pin a key; a bare # `encryption:` (a package/device split) is compatible with a # keyed one, and merge_config yields the keyed result - merged_key = ( - merged_ota_esphome_configs_by_port[conf_port] - .get(CONF_ENCRYPTION, {}) - .get(CONF_KEY) + merged_enc = merged_ota_esphome_configs_by_port[conf_port].get( + CONF_ENCRYPTION, {} ) - other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) - if merged_key and other_key and merged_key != other_key: - raise cv.Invalid( - f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent" - ) + other_enc = ota_conf.get(CONF_ENCRYPTION, {}) + for option in (CONF_KEY, CONF_OLD_KEY): + merged_key = merged_enc.get(option) + other_key = other_enc.get(option) + if merged_key and other_key and merged_key != other_key: + raise cv.Invalid( + f"Found multiple configurations but {CONF_ENCRYPTION} " + f"{option} is inconsistent" + ) ports_with_merged_configs.append(conf_port) merged_ota_esphome_configs_by_port[conf_port] = merge_config( @@ -132,20 +143,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 @@ -189,7 +192,7 @@ def ota_esphome_final_validate(config: ConfigType) -> None: CONF_WEB_SERVER, ) - full_conf[CONF_OTA] = new_ota_conf + full_conf[OTA_DOMAIN] = new_ota_conf fv.full_config.set(full_conf) if len(ports_with_merged_configs) > 0: @@ -206,7 +209,8 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - """Resolve the one encryption key per device into the ota block. An explicit ota key must match the api key, a bare block inherits it, - a runtime provisioned api key cannot be inherited. + a runtime provisioned api key cannot be inherited, and an old_key equal + to the resolved key is a leftover from a finished rotation. """ api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY) if ota_key := encryption_conf.get(CONF_KEY): @@ -229,17 +233,47 @@ def _resolve_encryption_key(encryption_conf: ConfigType, api_conf: ConfigType) - ) else: encryption_conf[CONF_KEY] = api_key + if encryption_conf.get(CONF_OLD_KEY) == encryption_conf[CONF_KEY]: + raise cv.Invalid( + f"'{CONF_OLD_KEY}' is the same as '{CONF_KEY}'; remove '{CONF_OLD_KEY}' " + "once the device runs the new key" + ) + + +# Uploader side options live only on the ota block; the api block keeps the +# shared schema. The firmware is built with `key` alone, `old_key` never +# reaches the device +_ENCRYPTION_SCHEMA = ENCRYPTION_SCHEMA.extend( + { + cv.Optional(CONF_OLD_KEY): cv.sensitive(validate_encryption_key), + 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 +303,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 +325,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 +347,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 +359,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 +370,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..e6b19c42f0 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -22,13 +22,15 @@ #include "esphome/core/lwip_fast_select.h" #endif +#include #include #include +#include #include namespace esphome { -static const char *const TAG = "esphome.ota"; +ESPHOME_LOG_TAG(TAG, "esphome.ota"); #ifdef USE_OTA_ENCRYPTION const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const { @@ -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); } } @@ -642,7 +635,7 @@ error: this->cleanup_connection_(); - this->status_momentary_error("err", 5000); + this->status_momentary_error(5000); #ifdef USE_OTA_STATE_LISTENER this->notify_state_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -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..38b58b0b03 --- /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 { + +ESPHOME_LOG_TAG(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..a271ef9d39 100644 --- a/esphome/components/esphome/ota/ota_esphome_noise.cpp +++ b/esphome/components/esphome/ota/ota_esphome_noise.cpp @@ -14,7 +14,7 @@ namespace esphome { -static const char *const TAG = "esphome.ota"; +ESPHOME_LOG_TAG(TAG, "esphome.ota"); #ifdef USE_ESP8266 static constexpr char OTA_NOISE_PROLOGUE_INIT[] PROGMEM = "NoiseOTAInit"; @@ -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 c04cf1ed59..e5abb16b1c 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -4,7 +4,9 @@ from esphome import automation, core import esphome.codegen as cg from esphome.components import wifi from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant +from esphome.components.esp32_hosted import DOMAIN as ESP32_HOSTED_DOMAIN from esphome.components.udp import CONF_ON_RECEIVE +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, @@ -15,6 +17,8 @@ from esphome.const import ( CONF_ON_ERROR, CONF_TRIGGER_ID, CONF_WIFI, + PLATFORM_ESP32, + PLATFORM_ESP8266, ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, TemplateArgsType @@ -22,9 +26,16 @@ import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] -AUTO_LOAD = ["network"] -byte_vector = cg.std_vector.template(cg.uint8) + +def AUTO_LOAD() -> list[str]: + # network initialises esp_netif and the default event loop on the ESP32. The ESP8266 needs neither, and + # loading it there would pull in mdns, which requires wifi + return ["network"] if CORE.is_esp32 else [] + + +DOMAIN = "espnow" + peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6) espnow_ns = cg.esphome_ns.namespace("espnow") @@ -119,15 +130,21 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ADDRESS): cv.mac_address, } ), - cv.Optional(CONF_ON_BROADCAST): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(OnBroadcastTrigger), - cv.Optional(CONF_ADDRESS): cv.mac_address, - } + # The ESP8266 SDK does not report the destination address, so broadcasts are indistinguishable there + cv.Optional(CONF_ON_BROADCAST): cv.All( + cv.only_on_esp32, + automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + OnBroadcastTrigger + ), + cv.Optional(CONF_ADDRESS): cv.mac_address, + } + ), ), }, ).extend(cv.COMPONENT_SCHEMA), - cv.only_on_esp32, + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266]), ) @@ -136,12 +153,14 @@ def _validate_variant(config: ConfigType) -> ConfigType: # ESP-NOW; only the ESP32-P4 has a path, via the esp32_hosted shim that # supplies the esp_now_* symbols. Fail here with a clear message instead of # letting the build reach an "undefined reference to esp_now_*" link error. + if not CORE.is_esp32: + return config variant = get_esp32_variant() if wifi.variant_has_wifi(variant): return config if variant != VARIANT_ESP32P4: raise cv.Invalid(f"ESP-NOW is not supported on {variant} (no Wi-Fi radio)") - if "esp32_hosted" not in fv.full_config.get(): + if ESP32_HOSTED_DOMAIN not in fv.full_config.get(): raise cv.Invalid(f"ESP-NOW on {variant} requires the esp32_hosted component") return config @@ -177,7 +196,7 @@ async def to_code(config: ConfigType) -> None: include_builtin_idf_component("esp_wifi") - if CONF_WIFI in CORE.config: + if WIFI_DOMAIN in CORE.config: # Track the Wi-Fi channel via connect events instead of polling every loop wifi.request_wifi_connect_state_listener() if wifi_channel := config.get(CONF_CHANNEL): @@ -304,11 +323,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])) diff --git a/esphome/components/espnow/automation.h b/esphome/components/espnow/automation.h index 3c9c6e8be9..88e240a646 100644 --- a/esphome/components/espnow/automation.h +++ b/esphome/components/espnow/automation.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "espnow_component.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) { @@ -140,4 +142,4 @@ class OnBroadcastTrigger final : public Trigger #include +#include +#include #include "esphome/core/defines.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include -#include -#include -#include -#include -#include -#include -#include - #ifdef USE_WIFI #include "esphome/components/wifi/wifi_component.h" #endif namespace esphome::espnow { -static constexpr const char *TAG = "espnow"; +ESPHOME_LOG_TAG(TAG, "espnow"); ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -49,7 +43,7 @@ static const LogString *espnow_error_to_str(esp_err_t error) { case ESP_ERR_ESPNOW_INTERNAL: return LOG_STR("Internal Error"); case ESP_ERR_ESPNOW_NO_MEM: - return LOG_STR("Our of memory"); + return LOG_STR("Out of memory"); case ESP_ERR_ESPNOW_NOT_FOUND: return LOG_STR("Peer not found"); case ESP_ERR_ESPNOW_IF: @@ -57,96 +51,91 @@ static const LogString *espnow_error_to_str(esp_err_t error) { case ESP_OK: return LOG_STR("OK"); case ESP_NOW_SEND_FAIL: + case ESP_FAIL: return LOG_STR("Failed"); default: return LOG_STR("Unknown Error"); } } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) -void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) -#else -void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) -#endif -{ +// The analyzer traces a leak on the failed-push path, which cannot happen: the +// pools are sized to the queue capacity (SIZE-1), so allocate() returns nullptr +// before push() can find the ring full. +// NOLINTBEGIN(clang-analyzer-unix.Malloc) +void ESPNowComponent::send_reported(const uint8_t *mac_addr, esp_now_send_status_t status) { // Allocate an event from the pool - ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + ESPNowPacket *packet = this->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } -// Load new packet data (replaces previous packet) -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - packet->load_sent_data(info->des_addr, status); -#else packet->load_sent_data(mac_addr, status); -#endif - // Push the packet to the queue - global_esp_now->receive_packet_queue_.push(packet); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. + this->receive_packet_queue_.push(packet); // Re-enable and wake the main loop to process the ESP-NOW send event - global_esp_now->enable_loop_soon_any_context(); + this->enable_loop_soon_any_context(); } -void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { +void ESPNowComponent::packet_received(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, int size, + int8_t rssi, uint32_t timestamp) { // Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a // v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B), - // but the receive buffer only fits v2 frames with ``max_payload_size``; copying a + // but the receive buffer only fits frames up to ``max_payload_size``; copying a // larger frame would overflow packet_.receive.data. - if (size < 0 || size > ESPNOW_MAX_DATA_LEN) { - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + if (size < 0 || static_cast(size) > ESPNOW_MAX_DATA_LEN) { + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } // Allocate an event from the pool - ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate(); + ESPNowPacket *packet = this->receive_packet_pool_.allocate(); if (packet == nullptr) { // No events available - queue is full or we're out of memory - global_esp_now->receive_packet_queue_.increment_dropped_count(); - global_esp_now->enable_loop_soon_any_context(); + this->receive_packet_queue_.increment_dropped_count(); + this->enable_loop_soon_any_context(); return; } - // Load new packet data (replaces previous packet) - packet->load_received_data(info, data, size); + packet->load_received_data(src_addr, des_addr, data, size, rssi, timestamp); - // Push the packet to the queue - global_esp_now->receive_packet_queue_.push(packet); // Push always succeeds: pool is sized to queue capacity (SIZE-1), so if // allocate() returned non-null, the queue cannot be full. + this->receive_packet_queue_.push(packet); // Re-enable and wake the main loop to process the ESP-NOW receive event - global_esp_now->enable_loop_soon_any_context(); + this->enable_loop_soon_any_context(); } +// NOLINTEND(clang-analyzer-unix.Malloc) ESPNowComponent::ESPNowComponent() { global_esp_now = this; } void ESPNowComponent::dump_config() { ESP_LOGCONFIG(TAG, "espnow:"); // Only report driver details once enabled; with enable_on_boot: false the - // Wi-Fi driver is not initialized yet and esp_now_get_version() would crash, + // Wi-Fi driver is not initialized yet and the version call would crash, // and after a failed enable_() the values would be meaningless. if (this->state_ != ESPNOW_STATE_ENABLED) { // OFF here means enable_() failed; the core logs the FAILED marker separately ESP_LOGCONFIG(TAG, " %s", this->is_disabled() ? LOG_STR_LITERAL("Disabled") : LOG_STR_LITERAL("Not enabled")); return; } - uint32_t version = 0; - esp_now_get_version(&version); char own_addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(this->own_address_, own_addr_buf); ESP_LOGCONFIG(TAG, " Own address: %s\n" - " Version: v%" PRIu32 "\n" " Wi-Fi channel: %d", - own_addr_buf, version, this->wifi_channel_); + own_addr_buf, this->wifi_channel_); + uint32_t version = platform::get_version(); + if (version != 0) { + ESP_LOGCONFIG(TAG, " Version: v%" PRIu32, version); + } #ifdef USE_WIFI ESP_LOGCONFIG(TAG, " Wi-Fi enabled: %s", YESNO(this->is_wifi_enabled())); #endif @@ -183,6 +172,10 @@ void ESPNowComponent::on_wifi_connect_state(StringRef ssid, std::spanwifi_channel_ != old_channel) { ESP_LOGI(TAG, "WiFi channel changed from %d to %d", old_channel, this->wifi_channel_); } + // Peers added while the station was scanning were bound to the channel of that moment, so rebind on every connect + if (this->state_ == ESPNOW_STATE_ENABLED) { + platform::rebind_peers(); + } } #endif @@ -198,41 +191,19 @@ void ESPNowComponent::enable() { void ESPNowComponent::enable_() { if (!this->is_wifi_enabled()) { - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - - ESP_ERROR_CHECK(esp_wifi_init(&cfg)); - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); - ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); - ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); - ESP_ERROR_CHECK(esp_wifi_start()); - ESP_ERROR_CHECK(esp_wifi_disconnect()); - + platform::init_radio(); this->apply_wifi_channel(); } this->get_wifi_channel(); - esp_err_t err = esp_now_init(); + esp_err_t err = platform::init(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_init failed: %s", esp_err_to_name(err)); + ESP_LOGE(TAG, "Init failed: %s", esp_err_to_name(err)); this->mark_failed(); return; } - err = esp_now_register_recv_cb(on_data_received); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - err = esp_now_register_send_cb(on_send_report); - if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_register_recv_cb failed: %s", esp_err_to_name(err)); - this->mark_failed(); - return; - } - - esp_wifi_get_mac(WIFI_IF_STA, this->own_address_); + platform::read_mac(this->own_address_); this->state_ = ESPNOW_STATE_ENABLED; @@ -248,12 +219,9 @@ void ESPNowComponent::disable() { ESP_LOGD(TAG, "Disabling"); this->state_ = ESPNOW_STATE_DISABLED; - esp_now_unregister_recv_cb(); - esp_now_unregister_send_cb(); - - esp_err_t err = esp_now_deinit(); + esp_err_t err = platform::deinit(); if (err != ESP_OK) { - ESP_LOGE(TAG, "esp_now_deinit failed! 0x%x", err); + ESP_LOGE(TAG, "Deinit failed: %s", esp_err_to_name(err)); } } @@ -271,9 +239,10 @@ void ESPNowComponent::apply_wifi_channel() { } ESP_LOGI(TAG, "Channel set to %d.", this->wifi_channel_); - esp_wifi_set_promiscuous(true); - esp_wifi_set_channel(this->wifi_channel_, WIFI_SECOND_CHAN_NONE); - esp_wifi_set_promiscuous(false); + platform::set_channel(this->wifi_channel_); + if (this->state_ == ESPNOW_STATE_ENABLED) { + platform::rebind_peers(); + } } void ESPNowComponent::loop() { @@ -283,7 +252,7 @@ void ESPNowComponent::loop() { switch (packet->type_) { case ESPNowPacket::RECEIVED: { const ESPNowRecvInfo info = packet->get_receive_info(); - if (!esp_now_is_peer_exist(info.src_addr)) { + if (!platform::peer_exists(info.src_addr)) { bool handled = false; for (auto *handler : this->unknown_peer_handlers_) { if (handler->on_unknown_peer(info, packet->packet_.receive.data, packet->packet_.receive.size)) { @@ -296,7 +265,7 @@ void ESPNowComponent::loop() { } } // Intentionally left as if instead of else in case the peer is added above - if (esp_now_is_peer_exist(info.src_addr)) { + if (platform::peer_exists(info.src_addr)) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char src_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char dst_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -309,7 +278,8 @@ void ESPNowComponent::loop() { format_hex_pretty_to(hex_buf, packet->packet_.receive.data, std::min(packet->packet_.receive.size, ESP_NOW_MAX_DATA_LEN))); #endif - if (memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { + // Without a destination address every packet goes to the receive handlers + if (ESPNOW_REPORTS_DESTINATION && memcmp(info.des_addr, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0) { for (auto *handler : this->broadcast_handlers_) { if (handler->on_broadcast(info, packet->packet_.receive.data, packet->packet_.receive.size)) break; // If a handler returns true, stop processing further handlers @@ -374,24 +344,25 @@ void ESPNowComponent::loop() { } uint8_t ESPNowComponent::get_wifi_channel() { - wifi_second_chan_t dummy; - esp_wifi_get_channel(&this->wifi_channel_, &dummy); + this->wifi_channel_ = platform::get_channel(); return this->wifi_channel_; } +// Same analyzer false positive as above: the send pool is sized to the send queue's capacity +// NOLINTBEGIN(clang-analyzer-unix.Malloc) esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payload, size_t size, - const send_callback_t &callback) { + send_callback_t callback) { if (this->state_ != ESPNOW_STATE_ENABLED) { return ESP_ERR_ESPNOW_NOT_INIT; } else if (this->is_failed()) { return ESP_ERR_ESPNOW_FAILED; - } else if (peer_address == 0ULL) { + } else if (peer_address == nullptr) { return ESP_ERR_ESPNOW_PEER_NOT_SET; } else if (memcmp(peer_address, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { return ESP_ERR_ESPNOW_OWN_ADDRESS; } else if (size > ESPNOW_MAX_DATA_LEN) { return ESP_ERR_ESPNOW_DATA_SIZE; - } else if (!esp_now_is_peer_exist(peer_address)) { + } else if (!platform::peer_exists(peer_address)) { if (memcmp(peer_address, ESPNOW_BROADCAST_ADDR, ESP_NOW_ETH_ALEN) == 0 || this->auto_add_peer_) { esp_err_t err = this->add_peer(peer_address); if (err != ESP_OK) { @@ -406,11 +377,11 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl if (packet == nullptr) { this->send_packet_queue_.increment_dropped_count(); ESP_LOGE(TAG, "Failed to allocate send packet from pool"); - this->status_momentary_warning("send-packet-pool-full"); + this->status_momentary_warning(); return ESP_ERR_ESPNOW_NO_MEM; } // Load the packet data - packet->load_data(peer_address, payload, size, callback); + packet->load_data(peer_address, payload, size, std::move(callback)); // Push the packet to the send queue this->send_packet_queue_.push(packet); // Loop may be disabled while idle; re-enable it to send the packet @@ -418,6 +389,7 @@ esp_err_t ESPNowComponent::send(const uint8_t *peer_address, const uint8_t *payl this->enable_loop_soon_any_context(); return ESP_OK; } +// NOLINTEND(clang-analyzer-unix.Malloc) void ESPNowComponent::send_() { ESPNowSendPacket *packet = this->send_packet_queue_.pop(); @@ -426,7 +398,7 @@ void ESPNowComponent::send_() { } this->current_send_packet_ = packet; - esp_err_t err = esp_now_send(packet->address_, packet->data_, packet->size_); + esp_err_t err = platform::send(packet->address_, packet->data_, packet->size_); if (err != ESP_OK) { char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(packet->address_, addr_buf); @@ -434,7 +406,7 @@ void ESPNowComponent::send_() { if (packet->callback_ != nullptr) { packet->callback_(err); } - this->status_momentary_warning("send-failed"); + this->status_momentary_warning(); this->send_packet_pool_.release(packet); this->current_send_packet_ = nullptr; // Reset current packet return; @@ -447,22 +419,17 @@ esp_err_t ESPNowComponent::add_peer(const uint8_t *peer) { } if (memcmp(peer, this->own_address_, ESP_NOW_ETH_ALEN) == 0) { - this->status_momentary_warning("peer-add-failed"); + this->status_momentary_warning(); return ESP_ERR_INVALID_MAC; } - if (!esp_now_is_peer_exist(peer)) { - esp_now_peer_info_t peer_info = {}; - memset(&peer_info, 0, sizeof(esp_now_peer_info_t)); - peer_info.ifidx = WIFI_IF_STA; - memcpy(peer_info.peer_addr, peer, ESP_NOW_ETH_ALEN); - esp_err_t err = esp_now_add_peer(&peer_info); - + if (!platform::peer_exists(peer)) { + esp_err_t err = platform::add_peer(peer); if (err != ESP_OK) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); ESP_LOGE(TAG, "Failed to add peer %s - %s", peer_buf, LOG_STR_ARG(espnow_error_to_str(err))); - this->status_momentary_warning("peer-add-failed"); + this->status_momentary_warning(); return err; } } @@ -486,13 +453,13 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { if (this->state_ != ESPNOW_STATE_ENABLED || this->is_failed()) { return ESP_ERR_ESPNOW_NOT_INIT; } - if (esp_now_is_peer_exist(peer)) { - esp_err_t err = esp_now_del_peer(peer); + if (platform::peer_exists(peer)) { + esp_err_t err = platform::del_peer(peer); if (err != ESP_OK) { char peer_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(peer, peer_buf); ESP_LOGE(TAG, "Failed to delete peer %s - %s", peer_buf, LOG_STR_ARG(espnow_error_to_str(err))); - this->status_momentary_warning("peer-del-failed"); + this->status_momentary_warning(); return err; } } @@ -507,4 +474,4 @@ esp_err_t ESPNowComponent::del_peer(const uint8_t *peer) { } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_component.h b/esphome/components/espnow/espnow_component.h index 375bd4faef..b912233eed 100644 --- a/esphome/components/espnow/espnow_component.h +++ b/esphome/components/espnow/espnow_component.h @@ -4,21 +4,17 @@ #include "esphome/core/component.h" #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/event_pool.h" #include "esphome/core/lock_free_queue.h" #include "espnow_packet.h" +#include "espnow_types.h" #if defined(USE_WIFI) && defined(USE_WIFI_CONNECT_STATE_LISTENERS) #include "esphome/components/wifi/wifi_component.h" #endif -#include - -#include -#include - #include #include #include @@ -27,10 +23,6 @@ namespace esphome::espnow { -// Maximum size of the ESPNow event queue - must be power of 2 for lock-free queue -static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; -static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; - using peer_address_t = std::array; enum class ESPNowTriggers : uint8_t { @@ -152,12 +144,10 @@ class ESPNowComponent final : public Component { /// @param payload Data payload to send /// @param callback Callback to call when the send operation is complete /// @return ESP_OK on success, or an error code on failure - esp_err_t send(const uint8_t *peer_address, const std::vector &payload, - const send_callback_t &callback = nullptr) { - return this->send(peer_address, payload.data(), payload.size(), callback); + esp_err_t send(const uint8_t *peer_address, const std::vector &payload, send_callback_t callback = nullptr) { + return this->send(peer_address, payload.data(), payload.size(), std::move(callback)); } - esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, - const send_callback_t &callback = nullptr); + esp_err_t send(const uint8_t *peer_address, const uint8_t *payload, size_t size, send_callback_t callback = nullptr); void register_receive_handler(ESPNowReceivedPacketHandler *handler) { this->receive_handlers_.push_back(handler); } void register_unknown_peer_handler(ESPNowUnknownPeerHandler *handler) { @@ -165,14 +155,13 @@ class ESPNowComponent final : public Component { } void register_broadcast_handler(ESPNowBroadcastHandler *handler) { this->broadcast_handlers_.push_back(handler); } - protected: - friend void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size); -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - friend void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status); -#else - friend void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status); -#endif + // Entry points for the radio driver callbacks in espnow_platform_*.cpp; they run outside the main loop and + // only queue the event + void packet_received(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, int size, int8_t rssi, + uint32_t timestamp); + void send_reported(const uint8_t *mac_addr, esp_now_send_status_t status); + protected: void enable_(); void send_(); @@ -205,4 +194,4 @@ extern ESPNowComponent *global_esp_now; // NOLINT(cppcoreguidelines-avoid-non-c } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_err.h b/esphome/components/espnow/espnow_err.h index ceda1b7683..8db38b800e 100644 --- a/esphome/components/espnow/espnow_err.h +++ b/esphome/components/espnow/espnow_err.h @@ -1,19 +1,18 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) -#include -#include +#include "espnow_types.h" namespace esphome::espnow { -static const esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); -static const esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); -static const esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); -static const esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); -static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); -static const esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); +static constexpr esp_err_t ESP_ERR_ESPNOW_CMP_BASE = (ESP_ERR_ESPNOW_BASE + 20); +static constexpr esp_err_t ESP_ERR_ESPNOW_FAILED = (ESP_ERR_ESPNOW_CMP_BASE + 1); +static constexpr esp_err_t ESP_ERR_ESPNOW_OWN_ADDRESS = (ESP_ERR_ESPNOW_CMP_BASE + 2); +static constexpr esp_err_t ESP_ERR_ESPNOW_DATA_SIZE = (ESP_ERR_ESPNOW_CMP_BASE + 3); +static constexpr esp_err_t ESP_ERR_ESPNOW_PEER_NOT_SET = (ESP_ERR_ESPNOW_CMP_BASE + 4); +static constexpr esp_err_t ESP_ERR_ESPNOW_PEER_NOT_PAIRED = (ESP_ERR_ESPNOW_CMP_BASE + 5); } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_packet.h b/esphome/components/espnow/espnow_packet.h index fb125864fb..d9f4a73f93 100644 --- a/esphome/components/espnow/espnow_packet.h +++ b/esphome/components/espnow/espnow_packet.h @@ -1,19 +1,17 @@ #pragma once -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "espnow_err.h" +#include "espnow_types.h" #include #include #include #include +#include #include -#include -#include -#include - namespace esphome::espnow { static const uint8_t ESPNOW_BROADCAST_ADDR[ESP_NOW_ETH_ALEN] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; @@ -36,16 +34,10 @@ static_assert(ESPNOW_MAX_DATA_LEN <= ESP_NOW_MAX_DATA_LEN, "espnow max_payload_size beyond 250 bytes requires an ESP-IDF with ESP-NOW v2 support (5.4+)"); #endif -struct WifiPacketRxControl { - int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm - uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or - // light sleep is not enabled -}; - struct ESPNowRecvInfo { uint8_t src_addr[ESP_NOW_ETH_ALEN]; /**< Source address of ESPNOW packet */ uint8_t des_addr[ESP_NOW_ETH_ALEN]; /**< Destination address of ESPNOW packet */ - wifi_pkt_rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ + rx_ctrl_t *rx_ctrl; /**< Rx control info of ESPNOW packet */ }; using send_callback_t = std::function; @@ -58,34 +50,28 @@ class ESPNowPacket { SENT, }; - // Constructor for received data - ESPNowPacket(const esp_now_recv_info_t *info, const uint8_t *data, int size) { - this->init_received_data_(info, data, size); - }; - -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) - // Constructor for sent data - ESPNowPacket(const esp_now_send_info_t *info, esp_now_send_status_t status) { - this->init_sent_data_(info->src_addr, status); - } -#else - // Constructor for sent data - ESPNowPacket(const uint8_t *mac_addr, esp_now_send_status_t status) { this->init_sent_data_(mac_addr, status); } -#endif - // Default constructor for pre-allocation in pool ESPNowPacket() {} void release() {} - void load_received_data(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + void load_received_data(const uint8_t *src_addr, const uint8_t *des_addr, const uint8_t *data, uint16_t size, + int8_t rssi, uint32_t timestamp) { this->type_ = RECEIVED; - this->init_received_data_(info, data, size); + memcpy(this->packet_.receive.info.src_addr, src_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.info.des_addr, des_addr, ESP_NOW_ETH_ALEN); + memcpy(this->packet_.receive.data, data, size); + this->packet_.receive.size = size; + + this->packet_.receive.rx_ctrl.rssi = rssi; + this->packet_.receive.rx_ctrl.timestamp = timestamp; + this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); } void load_sent_data(const uint8_t *mac_addr, esp_now_send_status_t status) { this->type_ = SENT; - this->init_sent_data_(mac_addr, status); + memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); + this->packet_.sent.status = status; } // Disable copy to prevent double-delete @@ -112,24 +98,6 @@ class ESPNowPacket { esp_now_packet_type_t type() const { return this->type_; } const ESPNowRecvInfo &get_receive_info() const { return this->packet_.receive.info; } - - private: - void init_received_data_(const esp_now_recv_info_t *info, const uint8_t *data, int size) { - memcpy(this->packet_.receive.info.src_addr, info->src_addr, ESP_NOW_ETH_ALEN); - memcpy(this->packet_.receive.info.des_addr, info->des_addr, ESP_NOW_ETH_ALEN); - memcpy(this->packet_.receive.data, data, size); - this->packet_.receive.size = size; - - this->packet_.receive.rx_ctrl.rssi = info->rx_ctrl->rssi; - this->packet_.receive.rx_ctrl.timestamp = info->rx_ctrl->timestamp; - - this->packet_.receive.info.rx_ctrl = reinterpret_cast(&this->packet_.receive.rx_ctrl); - } - - void init_sent_data_(const uint8_t *mac_addr, esp_now_send_status_t status) { - memcpy(this->packet_.sent.address, mac_addr, ESP_NOW_ETH_ALEN); - this->packet_.sent.status = status; - } }; class ESPNowSendPacket { @@ -151,9 +119,9 @@ class ESPNowSendPacket { ESPNowSendPacket(const ESPNowSendPacket &) = delete; ESPNowSendPacket &operator=(const ESPNowSendPacket &) = delete; - void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, const send_callback_t &callback) { + void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size, send_callback_t &&callback) { this->init_data_(peer_address, payload, size); - this->callback_ = callback; + this->callback_ = std::move(callback); } void load_data(const uint8_t *peer_address, const uint8_t *payload, size_t size) { @@ -180,4 +148,4 @@ class ESPNowSendPacket { } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_platform.h b/esphome/components/espnow/espnow_platform.h new file mode 100644 index 0000000000..bc19f34a7c --- /dev/null +++ b/esphome/components/espnow/espnow_platform.h @@ -0,0 +1,34 @@ +#pragma once + +#if defined(USE_ESP32) || defined(USE_ESP8266) + +#include "espnow_types.h" + +#include + +// The radio and ESP-NOW driver calls that differ between ESP-IDF and the ESP8266 NONOS SDK. Each platform +// implements the whole set in its own source file, so the component itself carries no platform checks. +namespace esphome::espnow::platform { + +// Bring up the station interface when no wifi component owns the radio +void init_radio(); +void set_channel(uint8_t channel); +uint8_t get_channel(); +void read_mac(uint8_t *mac); + +// Start the driver and route its callbacks to the component +esp_err_t init(); +esp_err_t deinit(); +uint32_t get_version(); // 0 where the SDK has no version call + +bool peer_exists(const uint8_t *mac); +esp_err_t add_peer(const uint8_t *mac); +esp_err_t del_peer(const uint8_t *mac); +// Move the registered peers to the radio's current channel; only the NONOS SDK binds peers to a channel +void rebind_peers(); + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size); + +} // namespace esphome::espnow::platform + +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/espnow_platform_esp32.cpp b/esphome/components/espnow/espnow_platform_esp32.cpp new file mode 100644 index 0000000000..b45c09b072 --- /dev/null +++ b/esphome/components/espnow/espnow_platform_esp32.cpp @@ -0,0 +1,96 @@ +#include "espnow_platform.h" + +#ifdef USE_ESP32 + +#include "espnow_component.h" + +#include +#include +#include + +#include + +namespace esphome::espnow::platform { + +static void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) { + global_esp_now->packet_received(info->src_addr, info->des_addr, data, size, info->rx_ctrl->rssi, + info->rx_ctrl->timestamp); +} + +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) +static void on_send_report(const esp_now_send_info_t *info, esp_now_send_status_t status) { + global_esp_now->send_reported(info->des_addr, status); +} +#else +static void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status) { + global_esp_now->send_reported(mac_addr, status); +} +#endif + +void init_radio() { + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM)); + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); + ESP_ERROR_CHECK(esp_wifi_start()); + ESP_ERROR_CHECK(esp_wifi_disconnect()); +} + +void set_channel(uint8_t channel) { + esp_wifi_set_promiscuous(true); + esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); + esp_wifi_set_promiscuous(false); +} + +uint8_t get_channel() { + uint8_t channel = 0; + wifi_second_chan_t second; + esp_wifi_get_channel(&channel, &second); + return channel; +} + +void read_mac(uint8_t *mac) { esp_wifi_get_mac(WIFI_IF_STA, mac); } + +esp_err_t init() { + esp_err_t err = esp_now_init(); + if (err == ESP_OK) { + err = esp_now_register_recv_cb(on_data_received); + } + if (err == ESP_OK) { + err = esp_now_register_send_cb(on_send_report); + } + return err; +} + +esp_err_t deinit() { + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + return esp_now_deinit(); +} + +uint32_t get_version() { + uint32_t version = 0; + esp_now_get_version(&version); + return version; +} + +bool peer_exists(const uint8_t *mac) { return esp_now_is_peer_exist(mac); } + +esp_err_t add_peer(const uint8_t *mac) { + esp_now_peer_info_t peer_info = {}; + peer_info.ifidx = WIFI_IF_STA; + memcpy(peer_info.peer_addr, mac, ESP_NOW_ETH_ALEN); + return esp_now_add_peer(&peer_info); +} + +esp_err_t del_peer(const uint8_t *mac) { return esp_now_del_peer(mac); } + +// Peers are added on channel 0, which ESP-IDF treats as whatever channel the radio is on +void rebind_peers() {} + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size) { return esp_now_send(mac, data, size); } + +} // namespace esphome::espnow::platform + +#endif // USE_ESP32 diff --git a/esphome/components/espnow/espnow_platform_esp8266.cpp b/esphome/components/espnow/espnow_platform_esp8266.cpp new file mode 100644 index 0000000000..736e878837 --- /dev/null +++ b/esphome/components/espnow/espnow_platform_esp8266.cpp @@ -0,0 +1,105 @@ +#include "espnow_platform.h" + +#ifdef USE_ESP8266 + +#include "espnow_component.h" + +namespace esphome::espnow::platform { + +// The SDK reports 0 for success and a non-zero value for any failure +static esp_err_t sdk_result(int result) { return result == 0 ? ESP_OK : ESP_FAIL; } + +// Stands in for the destination address, which the SDK does not report +static const uint8_t UNKNOWN_ADDR[ESP_NOW_ETH_ALEN] = {0}; + +static void on_data_received(uint8_t *mac_addr, uint8_t *data, uint8_t size) { + global_esp_now->packet_received(mac_addr, UNKNOWN_ADDR, data, size, 0, 0); +} + +static void on_send_report(uint8_t *mac_addr, uint8_t status) { + global_esp_now->send_reported(mac_addr, status == 0 ? ESP_NOW_SEND_SUCCESS : ESP_NOW_SEND_FAIL); +} + +void init_radio() { + // The NONOS SDK only delivers broadcast frames to ESP-NOW while the station is associated or the + // soft-AP interface is up. Without Wi-Fi, run a hidden, open soft-AP that accepts no clients so + // broadcasts (for example from a WizMote style remote) are received. + softap_config ap{}; + ap.channel = 1; + ap.authmode = AUTH_OPEN; + ap.ssid_hidden = 1; + ap.max_connection = 0; + ap.beacon_interval = 60000; + wifi_set_opmode_current(STATIONAP_MODE); + wifi_softap_set_config_current(&ap); + wifi_softap_dhcps_stop(); + wifi_set_sleep_type(NONE_SLEEP_T); + wifi_station_disconnect(); +} + +void set_channel(uint8_t channel) { + wifi_promiscuous_enable(true); + wifi_set_channel(channel); + wifi_promiscuous_enable(false); + // Keep the soft-AP from init_radio() on the same channel so the SDK never retunes the radio to its own + softap_config ap{}; + if (wifi_get_opmode() == STATIONAP_MODE && wifi_softap_get_config(&ap) && ap.channel != channel) { + ap.channel = channel; + wifi_softap_set_config_current(&ap); + } +} + +uint8_t get_channel() { return wifi_get_channel(); } + +void read_mac(uint8_t *mac) { + // Frames leave from the station interface unless the device runs as a soft-AP only + wifi_get_macaddr(wifi_get_opmode() == SOFTAP_MODE ? SOFTAP_IF : STATION_IF, mac); +} + +esp_err_t init() { + int result = esp_now_init(); + if (result == 0) { + // CONTROLLER sends from the station interface; the other roles send from the soft-AP MAC whenever + // that interface is up, so peers would no longer recognise the sender. + result = esp_now_set_self_role(ESP_NOW_ROLE_CONTROLLER); + } + if (result == 0) { + result = esp_now_register_recv_cb(on_data_received); + } + if (result == 0) { + result = esp_now_register_send_cb(on_send_report); + } + return sdk_result(result); +} + +esp_err_t deinit() { + esp_now_unregister_recv_cb(); + esp_now_unregister_send_cb(); + return sdk_result(esp_now_deinit()); +} + +uint32_t get_version() { return 0; } + +// The SDK returns 1 when the peer exists, 0 when it does not and a negative value on error +bool peer_exists(const uint8_t *mac) { return esp_now_is_peer_exist(const_cast(mac)) > 0; } + +esp_err_t add_peer(const uint8_t *mac) { + return sdk_result(esp_now_add_peer(const_cast(mac), ESP_NOW_ROLE_COMBO, wifi_get_channel(), nullptr, 0)); +} + +esp_err_t del_peer(const uint8_t *mac) { return sdk_result(esp_now_del_peer(const_cast(mac))); } + +void rebind_peers() { + const uint8_t channel = wifi_get_channel(); + for (uint8_t *mac = esp_now_fetch_peer(true); mac != nullptr; mac = esp_now_fetch_peer(false)) { + esp_now_set_peer_channel(mac, channel); + } +} + +esp_err_t send(const uint8_t *mac, const uint8_t *data, uint16_t size) { + return sdk_result(esp_now_send(const_cast(mac), const_cast(data), size)); +} + +} // namespace esphome::espnow::platform + +#endif // USE_ESP8266 diff --git a/esphome/components/espnow/espnow_types.h b/esphome/components/espnow/espnow_types.h new file mode 100644 index 0000000000..4b9c65fecf --- /dev/null +++ b/esphome/components/espnow/espnow_types.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include + +#if defined(USE_ESP32) + +#include +#include + +#elif defined(USE_ESP8266) + +extern "C" { +#include +#include +#include +} + +#endif + +namespace esphome::espnow { + +struct WifiPacketRxControl { + int8_t rssi; // Received Signal Strength Indicator (RSSI) of packet, unit: dBm + uint32_t timestamp; // Timestamp in microseconds when the packet was received, precise only if modem sleep or + // light sleep is not enabled +}; + +#if defined(USE_ESP32) + +// Handlers read rx_ctrl through the ESP-IDF type so existing lambdas keep compiling +using rx_ctrl_t = wifi_pkt_rx_ctrl_t; +static constexpr bool ESPNOW_REPORTS_DESTINATION = true; + +// Maximum size of the ESPNow event queues - must be a power of 2 for the lock-free queue +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 16; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 16; + +#elif defined(USE_ESP8266) + +// The NONOS SDK has no esp_err_t vocabulary. These are the ESP-IDF names and values the shared espnow code +// uses, so it reads the same on both platforms. +using esp_err_t = int32_t; + +static constexpr esp_err_t ESP_OK = 0; +static constexpr esp_err_t ESP_FAIL = -1; +static constexpr esp_err_t ESP_ERR_INVALID_MAC = 0x10B; +static constexpr esp_err_t ESP_ERR_ESPNOW_BASE = 0x3064; +static constexpr esp_err_t ESP_ERR_ESPNOW_NOT_INIT = ESP_ERR_ESPNOW_BASE + 1; +static constexpr esp_err_t ESP_ERR_ESPNOW_ARG = ESP_ERR_ESPNOW_BASE + 2; +static constexpr esp_err_t ESP_ERR_ESPNOW_NO_MEM = ESP_ERR_ESPNOW_BASE + 3; +static constexpr esp_err_t ESP_ERR_ESPNOW_NOT_FOUND = ESP_ERR_ESPNOW_BASE + 5; +static constexpr esp_err_t ESP_ERR_ESPNOW_INTERNAL = ESP_ERR_ESPNOW_BASE + 6; +static constexpr esp_err_t ESP_ERR_ESPNOW_IF = ESP_ERR_ESPNOW_BASE + 8; + +inline const char *esp_err_to_name(esp_err_t err) { return err == ESP_OK ? "ESP_OK" : "ESP_FAIL"; } + +static constexpr uint8_t ESP_NOW_ETH_ALEN = 6; +static constexpr uint8_t ESP_NOW_MAX_DATA_LEN = 250; + +// NOLINTNEXTLINE(readability-identifier-naming) +enum esp_now_send_status_t : uint8_t { + ESP_NOW_SEND_SUCCESS = 0, + ESP_NOW_SEND_FAIL = 1, +}; + +using rx_ctrl_t = WifiPacketRxControl; +// The SDK reports neither the destination address nor the signal strength of a received frame +static constexpr bool ESPNOW_REPORTS_DESTINATION = false; + +// Half the ESP32 depth: the heap is far smaller and only one send is ever in flight +static constexpr size_t MAX_ESP_NOW_SEND_QUEUE_SIZE = 8; +static constexpr size_t MAX_ESP_NOW_RECEIVE_QUEUE_SIZE = 8; + +#endif + +} // namespace esphome::espnow diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index b7686f23d6..93bc181a18 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -1,13 +1,15 @@ #include "espnow_transport.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/application.h" #include "esphome/core/log.h" +#include + namespace esphome::espnow { -static const char *const TAG = "espnow.transport"; +ESPHOME_LOG_TAG(TAG, "espnow.transport"); bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); } @@ -64,9 +66,7 @@ bool ESPNowTransport::on_receive(const ESPNowRecvInfo &info, const uint8_t *data return false; } - this->packet_buffer_.resize(size); - memcpy(this->packet_buffer_.data(), data, size); - this->process_(this->packet_buffer_); + this->process_(std::span(data, size)); return false; // Allow other handlers to run } @@ -79,12 +79,10 @@ bool ESPNowTransport::on_broadcast(const ESPNowRecvInfo &info, const uint8_t *da return false; } - this->packet_buffer_.resize(size); - memcpy(this->packet_buffer_.data(), data, size); - this->process_(this->packet_buffer_); + this->process_(std::span(data, size)); return false; // Allow other handlers to run } } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 51069b6415..79938f8df2 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -2,7 +2,7 @@ #include "../espnow_component.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ESP8266) #include "esphome/core/component.h" #include "esphome/components/packet_transport/packet_transport.h" @@ -33,9 +33,8 @@ class ESPNowTransport final : public packet_transport::PacketTransport, bool should_send() override; peer_address_t peer_address_{{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}; - std::vector packet_buffer_; }; } // namespace esphome::espnow -#endif // USE_ESP32 +#endif // USE_ESP32 || USE_ESP8266 diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 3e7d345805..31e4952d8e 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -import logging from esphome import automation, pins from esphome.automation import Condition @@ -53,23 +52,20 @@ 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 AUTO_LOAD = ["network"] -LOGGER = logging.getLogger(__name__) +DOMAIN = "ethernet" # Key for tracking IP state listener count in CORE.data ETHERNET_IP_STATE_LISTENERS_KEY = "ethernet_ip_state_listeners" # Key for tracking configured ethernet type ETHERNET_TYPE_KEY = "ethernet_type" -KEY_ETHERNET = "ethernet" def request_ethernet_ip_state_listener() -> None: @@ -140,6 +136,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 +159,7 @@ _PHY_TYPE_TO_DEFINE = { "GENERIC": "USE_ETHERNET_GENERIC", "YT8531": "USE_ETHERNET_YT8531", "CH390": "USE_ETHERNET_CH390", + "KSZ8851SNL": "USE_ETHERNET_KSZ8851SNL", } @@ -186,13 +184,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. @@ -213,15 +212,17 @@ CLK_MODES = { "CLK_OUT": emac_rmii_clock_mode_t.EMAC_CLK_OUT, } -CLK_MODES_DEPRECATED = { - "GPIO0_IN": ("CLK_EXT_IN", 0), - "GPIO0_OUT": ("CLK_OUT", 0), - "GPIO16_OUT": ("CLK_OUT", 16), - "GPIO17_OUT": ("CLK_OUT", 17), -} - spi_host_device_t = cg.global_ns.enum("spi_host_device_t") +# Must match the member initializers in ethernet_component.h; setters are skipped +# when the config already equals them. +DEFAULT_SPI_INTERFACE = "spi2" +DEFAULT_PHY_ADDR = 0 +DEFAULT_MDC_PIN = 23 +DEFAULT_MDIO_PIN = 18 +DEFAULT_CLK_MODE = "CLK_EXT_IN" +DEFAULT_CLK_PIN = 0 + SPI_INTERFACE_MAP = { "spi2": spi_host_device_t.SPI2_HOST, "spi3": spi_host_device_t.SPI3_HOST, @@ -386,23 +387,6 @@ def _validate(config: ConfigType) -> ConfigType: get_esp32_variant, ) - if CONF_CLK_MODE in config: - mode, pin = CLK_MODES_DEPRECATED[config[CONF_CLK_MODE]] - LOGGER.warning( - "[ethernet] The 'clk_mode' option is deprecated. " - "Please replace 'clk_mode: %s' with:\n" - " clk:\n" - " mode: %s\n" - " pin: %s\n" - "Removal scheduled for 2026.11.0.", - config[CONF_CLK_MODE], - mode, - pin, - ) - config[CONF_CLK] = CLK_SCHEMA({CONF_MODE: mode, CONF_PIN: pin}) - del config[CONF_CLK_MODE] - elif CONF_CLK not in config: - raise cv.Invalid("'clk' is a required option for [ethernet].") variant = get_esp32_variant() if variant not in (VARIANT_ESP32, VARIANT_ESP32P4): raise cv.Invalid( @@ -451,11 +435,20 @@ RMII_SCHEMA = cv.All( { cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_CLK_MODE): cv.enum( - CLK_MODES_DEPRECATED, upper=True, space="_" + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_CLK_MODE): cv.invalid( + "The 'clk_mode' option has been removed in ESPHome 2026.11.0.\n" + "Replace it with a 'clk:' block containing 'mode:' and 'pin:':\n" + " GPIO0_IN -> mode: CLK_EXT_IN, pin: GPIO0\n" + " GPIO0_OUT -> mode: CLK_OUT, pin: GPIO0\n" + " GPIO16_OUT -> mode: CLK_OUT, pin: GPIO16\n" + " GPIO17_OUT -> mode: CLK_OUT, pin: GPIO17" + ), + cv.Required(CONF_CLK): CLK_SCHEMA, + cv.Optional(CONF_PHY_ADDR, default=DEFAULT_PHY_ADDR): cv.int_range( + min=0, max=31 ), - cv.Optional(CONF_CLK): CLK_SCHEMA, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), } @@ -472,7 +465,9 @@ GENERIC_SCHEMA = cv.All( { cv.Required(CONF_MDC_PIN): pins.internal_gpio_output_pin_number, cv.Required(CONF_MDIO_PIN): pins.internal_gpio_output_pin_number, - cv.Optional(CONF_PHY_ADDR, default=0): cv.int_range(min=0, max=31), + cv.Optional(CONF_PHY_ADDR, default=DEFAULT_PHY_ADDR): cv.int_range( + min=0, max=31 + ), cv.Optional(CONF_POWER_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_PHY_REGISTERS): cv.ensure_list(PHY_REGISTER_SCHEMA), } @@ -536,6 +531,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 +552,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 +630,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]) @@ -651,7 +666,7 @@ async def to_code(config: ConfigType) -> None: # enable_on_boot defaults to true in C++ - only set if false if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) - CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] + CORE.data.setdefault(DOMAIN, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] if CONF_MANUAL_IP in config: cg.add_define("USE_ETHERNET_MANUAL_IP") @@ -681,6 +696,15 @@ async def to_code(config: ConfigType) -> None: CORE.add_job(final_step) +def _add_mdio_setters(var: cg.MockObj, config: ConfigType) -> None: + if (phy_addr := config[CONF_PHY_ADDR]) != DEFAULT_PHY_ADDR: + cg.add(var.set_phy_addr(phy_addr)) + if (mdc_pin := config[CONF_MDC_PIN]) != DEFAULT_MDC_PIN: + cg.add(var.set_mdc_pin(mdc_pin)) + if (mdio_pin := config[CONF_MDIO_PIN]) != DEFAULT_MDIO_PIN: + cg.add(var.set_mdio_pin(mdio_pin)) + + async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: from esphome.components.esp32 import ( add_idf_component, @@ -699,7 +723,8 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: cg.add(var.set_clk_pin(config[CONF_CLK_PIN])) cg.add(var.set_miso_pin(config[CONF_MISO_PIN])) cg.add(var.set_mosi_pin(config[CONF_MOSI_PIN])) - cg.add(var.set_interface(SPI_INTERFACE_MAP[config[CONF_INTERFACE]])) + if (interface := config[CONF_INTERFACE]) != DEFAULT_SPI_INTERFACE: + cg.add(var.set_interface(SPI_INTERFACE_MAP[interface])) cg.add(var.set_cs_pin(config[CONF_CS_PIN])) if CONF_INTERRUPT_PIN in config: cg.add(var.set_interrupt_pin(config[CONF_INTERRUPT_PIN])) @@ -728,33 +753,19 @@ async def _to_code_esp32(var: cg.MockObj, config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_ETH_USE_OPENETH", True) elif config[CONF_TYPE] in ("GENERIC", "YT8531"): # RGMII data pins come from the IDF default config; set MDC/MDIO + PHY addr. - cg.add(var.set_phy_addr(config[CONF_PHY_ADDR])) - cg.add(var.set_mdc_pin(config[CONF_MDC_PIN])) - cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) + _add_mdio_setters(var, config) 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])) - cg.add(var.set_mdio_pin(config[CONF_MDIO_PIN])) - cg.add(var.set_clk_mode(config[CONF_CLK][CONF_MODE])) - cg.add(var.set_clk_pin(config[CONF_CLK][CONF_PIN])) + _add_mdio_setters(var, config) + if (clk_mode := config[CONF_CLK][CONF_MODE]) != DEFAULT_CLK_MODE: + cg.add(var.set_clk_mode(clk_mode)) + if (clk_pin := config[CONF_CLK][CONF_PIN]) != DEFAULT_CLK_PIN: + cg.add(var.set_clk_pin(clk_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, @@ -904,7 +915,7 @@ _define_filter = filter_source_files_from_defines( def _filter_source_files() -> list[str]: excluded = _platform_filter() + _define_filter() - eth_data = CORE.data.get(KEY_ETHERNET, {}) + eth_data = CORE.data.get(DOMAIN, {}) eth_type = eth_data.get(ETHERNET_TYPE_KEY) # Only compile the custom JL1101 driver when JL1101 is configured # and pioarduino doesn't have it builtin (IDF 5.4.2 to 5.x) @@ -924,24 +935,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..8d5552c6cc 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_; @@ -263,7 +271,7 @@ class EthernetComponent final : public Component { int reset_pin_{-1}; int phy_addr_spi_{-1}; int clock_speed_; - spi_host_device_t interface_{SPI2_HOST}; + spi_host_device_t interface_{SPI2_HOST}; // Must match DEFAULT_SPI_INTERFACE in __init__.py #ifdef USE_SPI // When set, the SPI bus is owned and initialized by this spi component // and the ethernet chip only adds a device to it. @@ -275,14 +283,16 @@ class EthernetComponent final : public Component { #else // 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_{}; + emac_rmii_clock_mode_t clk_mode_{EMAC_CLK_EXT_IN}; // Must match DEFAULT_CLK_MODE in __init__.py +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT + StaticVector phy_registers_{}; +#endif // Group all 8-bit members together - uint8_t clk_pin_{0}; - uint8_t phy_addr_{0}; - uint8_t mdc_pin_{23}; - uint8_t mdio_pin_{18}; + uint8_t clk_pin_{0}; // Must match DEFAULT_CLK_PIN in __init__.py + uint8_t phy_addr_{0}; // Must match DEFAULT_PHY_ADDR in __init__.py + uint8_t mdc_pin_{23}; // Must match DEFAULT_MDC_PIN in __init__.py + uint8_t mdio_pin_{18}; // Must match DEFAULT_MDIO_PIN in __init__.py #endif // USE_ETHERNET_SPI // ESP32 pointers diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 1d9903271e..1568d37b11 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,13 @@ #include #include #include "esp_event.h" +#if USE_NETWORK_IPV6 +#include +#include +#endif +#ifdef USE_PSRAM +#include +#endif // IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry; // they are no longer included via esp_eth.h and need explicit includes. @@ -38,6 +45,10 @@ #include "esp_eth_mac_dm9051.h" #include "esp_eth_phy_dm9051.h" #endif +#ifdef USE_ETHERNET_KSZ8851SNL +#include "esp_eth_mac_ksz8851snl.h" +#include "esp_eth_phy_ksz8851snl.h" +#endif #endif // ESP_IDF_VERSION >= 6.0.0 // LAN867x header exists on all IDF versions (external component since IDF 5.3) @@ -66,11 +77,37 @@ namespace esphome::ethernet { -static const char *const TAG = "ethernet"; +ESPHOME_LOG_TAG(TAG, "ethernet"); // PHY register size for hex logging static constexpr size_t PHY_REG_SIZE = 2; +// Dual wifi + ethernet SPI builds: the one place internal RAM is short and lwip's other buffers are +// already in PSRAM. Not with L2 TAP, whose filter lives in the glue's input path this replaces. +#if defined(USE_PSRAM) && defined(USE_ETHERNET_SPI) && defined(USE_WIFI) && !defined(CONFIG_ESP_NETIF_L2_TAP) +#define USE_ETHERNET_RX_PSRAM +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - reported by dump_config() +static bool rx_psram_installed = false; + +// ESP-IDF ethernet drivers malloc() every received frame in internal RAM, where it stays until lwIP +// hands it to the application. Move it to PSRAM; if that fails the frame is passed on where it is. +static esp_err_t eth_input_to_psram(esp_eth_handle_t handle, uint8_t *buffer, uint32_t length, void *priv) { + auto *copy = static_cast(heap_caps_malloc(length, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (copy != nullptr) { + memcpy(copy, buffer, length); + free(buffer); // NOLINT(cppcoreguidelines-no-malloc) - allocated by the driver with malloc() + buffer = copy; + } else { + static bool warned = false; // once, this runs per frame in the driver's task + if (!warned) { + warned = true; + ESP_LOGW(TAG, "PSRAM allocation failed, frame kept in internal RAM (reported once)"); + } + } + return esp_netif_receive(static_cast(priv), buffer, length, nullptr); +} +#endif + void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) { ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err)); this->mark_failed(); @@ -235,6 +272,8 @@ void EthernetComponent::ethernet_lazy_init_() { eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg); #elif defined(USE_ETHERNET_CH390) eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg); +#elif defined(USE_ETHERNET_KSZ8851SNL) + eth_ksz8851snl_config_t ksz8851snl_config = ETH_KSZ8851SNL_DEFAULT_CONFIG(host, &devcfg); #endif #if defined(USE_ETHERNET_W5500) @@ -261,6 +300,11 @@ void EthernetComponent::ethernet_lazy_init_() { #ifdef USE_ETHERNET_SPI_POLLING_SUPPORT ch390_config.poll_period_ms = this->polling_interval_; #endif +#elif defined(USE_ETHERNET_KSZ8851SNL) + ksz8851snl_config.int_gpio_num = this->interrupt_pin_; +#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT + ksz8851snl_config.poll_period_ms = this->polling_interval_; +#endif #endif phy_config.phy_addr = this->phy_addr_spi_; @@ -391,6 +435,12 @@ void EthernetComponent::ethernet_lazy_init_() { this->phy_ = esp_eth_phy_new_ch390(&phy_config); break; } +#elif defined(USE_ETHERNET_KSZ8851SNL) + case ETHERNET_TYPE_KSZ8851SNL: { + mac = esp_eth_mac_new_ksz8851snl(&ksz8851snl_config, &mac_config); + this->phy_ = esp_eth_phy_new_ksz8851snl(&phy_config); + break; + } #endif #endif default: { @@ -412,9 +462,11 @@ void EthernetComponent::ethernet_lazy_init_() { } #endif // USE_ETHERNET_KSZ8081 +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT for (const auto &phy_register : this->phy_registers_) { this->write_phy_register_(mac, phy_register); } +#endif #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0) #ifdef USE_ETHERNET_GENERIC @@ -453,6 +505,28 @@ void EthernetComponent::ethernet_lazy_init_() { /* attach Ethernet driver to TCP/IP stack */ err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_)); ESPHL_ERROR_CHECK(err, "ETH netif attach error"); +#ifdef USE_ETHERNET_RX_PSRAM + // The glue frees every receive buffer with free(), so the replacement buffer must come from the heap + if (esp_psram_is_initialized()) { + err = esp_eth_update_input_path(this->eth_handle_, eth_input_to_psram, this->eth_netif_); + rx_psram_installed = err == ESP_OK; + if (!rx_psram_installed) { + ESP_LOGW(TAG, "PSRAM RX path not installed: %s", esp_err_to_name(err)); + } + } +#endif +#if USE_NETWORK_IPV6 && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // Since IDF 5.5 the internal EMAC drops multicast groups that were never added (before, + // it passed all multicast), and lwIP never adds all-nodes, so router advertisements + // were lost and SLAAC never ran. + { + uint8_t all_nodes[6] = {0x33, 0x33, 0x00, 0x00, 0x00, 0x01}; + if (esp_err_t filter_err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_ADD_MAC_FILTER, all_nodes); + filter_err != ESP_OK) { + ESP_LOGD(TAG, "IPv6 all-nodes multicast filter not added: %s", esp_err_to_name(filter_err)); + } + } +#endif // Register user defined event handers err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); @@ -554,6 +628,10 @@ void EthernetComponent::dump_config() { case ETHERNET_TYPE_CH390: eth_type = "CH390"; break; +#elif defined(USE_ETHERNET_KSZ8851SNL) + case ETHERNET_TYPE_KSZ8851SNL: + eth_type = "KSZ8851SNL"; + break; #endif #ifdef USE_ETHERNET_OPENETH case ETHERNET_TYPE_OPENETH: @@ -630,6 +708,13 @@ void EthernetComponent::dump_config() { this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_); #endif ESP_LOGCONFIG(TAG, " Type: %s", eth_type); +#ifdef USE_ETHERNET_RX_PSRAM + // Only known once the driver is up; with enable_on_boot: false that is after this dump + if (this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " RX frames: %s", + rx_psram_installed ? LOG_STR_LITERAL("PSRAM") : LOG_STR_LITERAL("internal RAM")); + } +#endif } network::IPAddresses EthernetComponent::get_ip_addresses() { @@ -688,6 +773,13 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base global_eth_component->notify_ip_state_listeners_(); } #endif +#if USE_NETWORK_IPV6 + // Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the + // link-local after a link flap, which clears the IPv6 addresses. + if (esp_err_t ll_err = esp_netif_create_ip6_linklocal(global_eth_component->eth_netif_); ll_err != ESP_OK) { + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed on link-up: %s", esp_err_to_name(ll_err)); + } +#endif /* USE_NETWORK_IPV6 */ break; case ETHERNET_EVENT_DISCONNECTED: event_name = "ETH disconnected"; @@ -724,7 +816,10 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ void *event_data) { ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data; ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip)); - global_eth_component->ipv6_count_ += 1; + // Count the addresses on the interface, not the events: recreating the link-local + // after a link flap fires another event for the same address. + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s); #if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) global_eth_component->connected_ = global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT); @@ -739,6 +834,29 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ } #endif /* USE_NETWORK_IPV6 */ +#if USE_NETWORK_IPV6 +// Create the link-local address unless the interface already has one, including one still in +// duplicate address detection: recreating it would restart DAD. esp_netif_get_ip6_linklocal() +// only reports a preferred address, so ask lwIP for the slot state instead. +esp_err_t EthernetComponent::ensure_ip6_linklocal_() { + if (auto *netif = static_cast(esp_netif_get_netif_impl(this->eth_netif_)); netif != nullptr) { + u8_t state; + { + LwIPLock lock; + state = netif_ip6_addr_state(netif, 0); + } + if (ip6_addr_istentative(state) || ip6_addr_isvalid(state)) { + return ESP_OK; + } + } + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created"); + } + return err; +} +#endif /* USE_NETWORK_IPV6 */ + void EthernetComponent::finish_connect_() { #if USE_NETWORK_IPV6 // Retry IPv6 link-local setup if it failed during initial connect @@ -749,10 +867,7 @@ void EthernetComponent::finish_connect_() { // - Cable unplugged/network interruption (#10705) // We can now retry since we're in CONNECTED state and the interface is definitely up. if (!this->ipv6_setup_done_) { - esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); - if (err == ESP_OK) { - ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); - } + this->ensure_ip6_linklocal_(); // Always set the flag to prevent continuous retries // If IPv6 setup fails here with the interface up and stable, it's // likely a persistent issue (IPv6 disabled at router, hardware @@ -766,7 +881,9 @@ void EthernetComponent::finish_connect_() { void EthernetComponent::start_connect_() { global_eth_component->got_ipv4_address_ = false; #if USE_NETWORK_IPV6 - global_eth_component->ipv6_count_ = 0; + // Recount rather than zero: addresses that survive a reconnect are not announced again. + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); this->ipv6_setup_done_ = false; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); @@ -845,7 +962,7 @@ void EthernetComponent::start_connect_() { // - At bootup when link isn't ready (#10281) // - After disconnection/cable unplugged (#10705) // We'll retry in finish_connect_() if it fails here. - err = esp_netif_create_ip6_linklocal(this->eth_netif_); + err = this->ensure_ip6_linklocal_(); if (err != ESP_OK) { if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { // This is a programming error, not a transient failure @@ -928,10 +1045,6 @@ void EthernetComponent::dump_connect_params_() { #endif /* USE_NETWORK_IPV6 */ } -#ifndef USE_ETHERNET_SPI -void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } -#endif - void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { if (!this->ethernet_initialized_) { // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless @@ -1029,6 +1142,7 @@ void EthernetComponent::ksz8081_set_clock_reference_(esp_eth_mac_t *mac) { } #endif // USE_ETHERNET_KSZ8081 +#ifdef ESPHOME_ETHERNET_PHY_REGISTER_COUNT void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister register_data) { esp_err_t err; @@ -1053,6 +1167,7 @@ void EthernetComponent::write_phy_register_(esp_eth_mac_t *mac, PHYRegister regi } #endif } +#endif // ESPHOME_ETHERNET_PHY_REGISTER_COUNT #ifdef USE_ETHERNET_YT8531 void EthernetComponent::yt8531_phy_init_() { diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 94d84cc891..ecb238419e 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -14,7 +14,7 @@ namespace esphome::ethernet { -static const char *const TAG = "ethernet"; +ESPHOME_LOG_TAG(TAG, "ethernet"); void EthernetComponent::setup() { // Configure SPI pins diff --git a/esphome/components/ethernet_info/__init__.py b/esphome/components/ethernet_info/__init__.py index 312688d3fd..3f72edbc7d 100644 --- a/esphome/components/ethernet_info/__init__.py +++ b/esphome/components/ethernet_info/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@gtjadsonsantos"] +DOMAIN = "ethernet_info" diff --git a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp index 15ef6a1f20..e75d3eb9ae 100644 --- a/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp +++ b/esphome/components/ethernet_info/ethernet_info_text_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::ethernet_info { -static const char *const TAG = "ethernet_info"; +ESPHOME_LOG_TAG(TAG, "ethernet_info"); #ifdef USE_ETHERNET_IP_STATE_LISTENERS void IPAddressEthernetInfo::setup() { ethernet::global_eth_component->add_ip_state_listener(this); } diff --git a/esphome/components/event/__init__.py b/esphome/components/event/__init__.py index d02863b9ee..b09d979141 100644 --- a/esphome/components/event/__init__.py +++ b/esphome/components/event/__init__.py @@ -27,6 +27,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@nohat"] +DOMAIN = "event" IS_PLATFORM_COMPONENT = True DEVICE_CLASSES = [ @@ -97,7 +98,9 @@ async def setup_event_core_( ) -> None: await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) - cg.add(var.set_event_types(event_types)) + if event_types: + table = cg.shared_progmem_array("event_types", cg.const_char_ptr, event_types) + cg.add(var.set_event_types_static(table, len(event_types))) setup_device_class(config) diff --git a/esphome/components/event/automation.h b/esphome/components/event/automation.h deleted file mode 100644 index ee1d1a13c4..0000000000 --- a/esphome/components/event/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "esphome/components/event/event.h" -#include "esphome/core/automation.h" -#include "esphome/core/component.h" - -namespace esphome::event { - -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 00bb9497f9..671e11e7d2 100644 --- a/esphome/components/event/event.cpp +++ b/esphome/components/event/event.cpp @@ -5,7 +5,7 @@ namespace esphome::event { -static const char *const TAG = "event"; +ESPHOME_LOG_TAG(TAG, "event"); void Event::trigger(const char *event_type) { // Linear search with strcmp - faster than std::set for small datasets (1-5 items typical) @@ -28,11 +28,8 @@ void Event::trigger(const char *event_type) { #endif } -void Event::set_event_types(const FixedVector &event_types) { - this->types_.init(event_types.size()); - for (const char *type : event_types) { - this->types_.push_back(type); - } +void Event::set_event_types_copy_(const char *const *event_types, size_t count) { + this->types_.assign_copy(event_types, count); this->last_event_type_ = nullptr; // Reset when types change } diff --git a/esphome/components/event/event.h b/esphome/components/event/event.h index f57327e33f..203ee52ac8 100644 --- a/esphome/components/event/event.h +++ b/esphome/components/event/event.h @@ -11,6 +11,9 @@ namespace esphome::event { +/// A codegen table in flash, or a heap copy of a runtime list (see ConstVector). +using EventTypes = ConstVector; + #define LOG_EVENT(prefix, type, obj) \ if ((obj) != nullptr) { \ ESP_LOGCONFIG(TAG, "%s%s '%s'", prefix, LOG_STR_LITERAL(type), (obj)->get_name().c_str()); \ @@ -24,20 +27,29 @@ class Event : public EntityBase { 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; called by generated code with string literals. + /// Codegen only: points at a table that outlives the event (see ConstVector::assign_static). + void set_event_types_static(const char *const *event_types, size_t count) { + this->types_.assign_static(event_types, count); + this->last_event_type_ = nullptr; + } + /// Runtime lists are copied; the strings must still outlive the event. void set_event_types(std::initializer_list event_types) { - this->types_ = event_types; - this->last_event_type_ = nullptr; // Reset when types change + this->set_event_types_copy_(event_types.begin(), event_types.size()); } /// Copy the event types of another event, for components that wrap one. - void set_event_types(const FixedVector &event_types); + void set_event_types(const EventTypes &event_types) { + this->set_event_types_copy_(event_types.data(), event_types.size()); + } + void set_event_types(const FixedVector &event_types) { + this->set_event_types_copy_(event_types.begin(), event_types.size()); + } // 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; /// Return the event types supported by this event. - const FixedVector &get_event_types() const { return this->types_; } + const EventTypes &get_event_types() const { return this->types_; } /// Return the last triggered event type, or empty StringRef if no event triggered yet. StringRef get_last_event_type() const { return StringRef::from_maybe_nullptr(this->last_event_type_); } @@ -68,8 +80,10 @@ class Event : public EntityBase { } protected: + void set_event_types_copy_(const char *const *event_types, size_t count); + LazyCallbackManager event_callback_; - FixedVector types_; + EventTypes types_; private: /// Last triggered event type - must point to entry in types_ to ensure valid lifetime. diff --git a/esphome/components/exponential_moving_average/__init__.py b/esphome/components/exponential_moving_average/__init__.py new file mode 100644 index 0000000000..7a389785e7 --- /dev/null +++ b/esphome/components/exponential_moving_average/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@clydebarrow"] +DOMAIN = "exponential_moving_average" 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..ff4eded9cf --- /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 { + +ESPHOME_LOG_TAG(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/exposure_notifications/__init__.py b/esphome/components/exposure_notifications/__init__.py index 4f7e698e23..adcfa83717 100644 --- a/esphome/components/exposure_notifications/__init__.py +++ b/esphome/components/exposure_notifications/__init__.py @@ -10,6 +10,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] AUTO_LOAD = ["ble_device_base"] +DOMAIN = "exposure_notifications" exposure_notifications_ns = cg.esphome_ns.namespace("exposure_notifications") ExposureNotification = exposure_notifications_ns.struct("ExposureNotification") diff --git a/esphome/components/exposure_notifications/exposure_notifications.cpp b/esphome/components/exposure_notifications/exposure_notifications.cpp index 4f4b93b59c..063d7ba678 100644 --- a/esphome/components/exposure_notifications/exposure_notifications.cpp +++ b/esphome/components/exposure_notifications/exposure_notifications.cpp @@ -6,7 +6,7 @@ namespace esphome::exposure_notifications { using namespace ble_device_base; -static const char *const TAG = "exposure_notifications"; +ESPHOME_LOG_TAG(TAG, "exposure_notifications"); bool ExposureNotificationTrigger::parse_device(const ESPBTDevice &device) { // See also https://blog.google/documents/70/Exposure_Notification_-_Bluetooth_Specification_v1.2.2.pdf diff --git a/esphome/components/external_components/__init__.py b/esphome/components/external_components/__init__.py index 504b1ae679..8994805432 100644 --- a/esphome/components/external_components/__init__.py +++ b/esphome/components/external_components/__init__.py @@ -6,7 +6,6 @@ from esphome import git, loader import esphome.config_validation as cv from esphome.const import ( CONF_COMPONENTS, - CONF_EXTERNAL_COMPONENTS, CONF_PASSWORD, CONF_PATH, CONF_REF, @@ -22,7 +21,7 @@ from esphome.core import CORE, TimePeriodSeconds _LOGGER = logging.getLogger(__name__) -DOMAIN = CONF_EXTERNAL_COMPONENTS +DOMAIN = "external_components" CONFIG_SCHEMA = cv.ensure_list( diff --git a/esphome/components/ezo/__init__.py b/esphome/components/ezo/__init__.py index e69de29bb2..60408a40ee 100644 --- a/esphome/components/ezo/__init__.py +++ b/esphome/components/ezo/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ezo" diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 0793495e1a..aed071e8cf 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CODEOWNERS = ["@carlos-sarmiento"] DEPENDENCIES = ["i2c"] +DOMAIN = "ezo_pmp" MULTI_CONF = True CONF_VOLUME_PER_MINUTE = "volume_per_minute" @@ -44,112 +45,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 +67,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 +86,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 +108,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 +127,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 +141,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 +155,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.cpp b/esphome/components/ezo_pmp/ezo_pmp.cpp index 21307fcd7a..8db57670ae 100644 --- a/esphome/components/ezo_pmp/ezo_pmp.cpp +++ b/esphome/components/ezo_pmp/ezo_pmp.cpp @@ -4,7 +4,7 @@ namespace esphome::ezo_pmp { -static const char *const TAG = "ezo-pmp"; +ESPHOME_LOG_TAG(TAG, "ezo-pmp"); static const uint16_t EZO_PMP_COMMAND_NONE = 0; static const uint16_t EZO_PMP_COMMAND_TYPE_READ = 1; 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/ezsp_proxy_tap/__init__.py b/esphome/components/ezsp_proxy_tap/__init__.py new file mode 100644 index 0000000000..207e0cb8b1 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import serial_proxy +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81", "@puddly"] +DEPENDENCIES = ["serial_proxy"] +DOMAIN = "ezsp_proxy_tap" + +CONF_SERIAL_PROXY_ID = "serial_proxy_id" + +ezsp_proxy_tap_ns = cg.esphome_ns.namespace("ezsp_proxy_tap") +EzspProxyTap = ezsp_proxy_tap_ns.class_( + "EzspProxyTap", cg.Component, serial_proxy.SerialProxyTap +) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(EzspProxyTap), + cv.Required(CONF_SERIAL_PROXY_ID): cv.use_id(serial_proxy.SerialProxy), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + sp = await cg.get_variable(config[CONF_SERIAL_PROXY_ID]) + var = cg.new_Pvariable(config[CONF_ID], sp) + await cg.register_component(var, config) + + cg.add_define("USE_EZSP_PROXY_TAP") + # Compiles the tap interface into serial_proxy; without it the port is a plain byte pipe + cg.add_define("USE_SERIAL_PROXY_TAP") diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp new file mode 100644 index 0000000000..f8010f4618 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.cpp @@ -0,0 +1,126 @@ +#include "ash_acknowledger.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +// Control byte of an RSTACK, and the only ASH version byte that can follow it +static constexpr uint8_t ASH_RSTACK_CONTROL = 0xC1; +static constexpr uint8_t ASH_PROTOCOL_VERSION = 0x02; +static constexpr size_t ASH_RSTACK_BODY_SIZE = 3; // control, version, reset code +// A DATA frame carries an EZSP frame of at least 3 bytes after its control byte +static constexpr size_t ASH_MIN_DATA_BODY_SIZE = 1 + 3; +static constexpr size_t ASH_CRC_SIZE = 2; +// Smallest legal frame on the wire: a bare control byte plus its CRC +static constexpr size_t ASH_MIN_FRAME_SIZE = 1 + ASH_CRC_SIZE; + +void AshFrameScanner::reset() { + this->index_ = 0; + this->crc_ = ASH_CRC_INIT; + this->escaped_ = false; + this->poisoned_ = false; + this->discarding_ = false; +} + +bool AshFrameScanner::feed(uint8_t byte) { + if (byte == ASH_FLAG_BYTE) { + // Running the CRC over the body and its trailing CRC bytes leaves zero when correct + const bool valid = !this->discarding_ && !this->poisoned_ && !this->escaped_ && + this->index_ >= ASH_MIN_FRAME_SIZE && this->crc_ == 0; + if (valid) { + this->frame_length_ = this->index_ - ASH_CRC_SIZE; + } + this->reset(); + return valid; + } + + // CANCEL also ends a SUBSTITUTE's discard, so the bytes after it start a new frame + if (byte == ASH_CANCEL_BYTE) { + this->reset(); + return false; + } + + if (this->discarding_ || (byte == ASH_WAKE_BYTE && this->index_ == 0)) { + return false; + } + + switch (byte) { + case ASH_SUBSTITUTE_BYTE: + this->discarding_ = true; + return false; + + case ASH_XON_BYTE: + case ASH_XOFF_BYTE: + return false; + + case ASH_ESCAPE_BYTE: + this->escaped_ = true; + return false; + + default: + break; + } + + uint8_t value = byte; + if (this->escaped_) { + this->escaped_ = false; + value = byte ^ ASH_XOR_BYTE; + } + + if (this->index_ >= MAX_ASH_FRAME_SIZE) { + this->poisoned_ = true; + return false; + } + + if (this->index_ < ASH_HEADER_SIZE) { + this->header_[this->index_] = value; + } + this->index_++; + this->crc_ = crc16be(&value, 1, this->crc_); + return false; +} + +void AshAcknowledger::reset() { + this->scanner_.reset(); + this->rx_sequence_ = 0; + this->synced_ = false; +} + +bool AshAcknowledger::feed(uint8_t byte) { return this->scanner_.feed(byte) && this->handle_frame_(); } + +bool AshAcknowledger::handle_frame_() { + const uint8_t *body = this->scanner_.header(); + const uint8_t control = body[0]; + + // An RSTACK restarts the NCP's frame numbering + if (control == ASH_RSTACK_CONTROL) { + if (this->scanner_.length() == ASH_RSTACK_BODY_SIZE && body[1] == ASH_PROTOCOL_VERSION) { + this->rx_sequence_ = 0; + this->synced_ = true; + } + return false; + } + + if ((control & 0x80) != 0) { + return false; // ACK/NAK/RST/ERROR: nothing is owed for these + } + + if (this->scanner_.length() < ASH_MIN_DATA_BODY_SIZE) { + return false; + } + + const uint8_t frame_num = (control >> 4) & ASH_MAX_SEQUENCE; + const bool re_tx = (control & 0x08) != 0; + // Without an RSTACK, the session began before we were watching: follow the NCP's numbering + if (!this->synced_) { + this->rx_sequence_ = frame_num; + this->synced_ = true; + } + if (frame_num == this->rx_sequence_) { + this->rx_sequence_ = (this->rx_sequence_ + 1) & ASH_MAX_SEQUENCE; + return true; + } + return re_tx; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_acknowledger.h b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h new file mode 100644 index 0000000000..1c39e8ec4a --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_acknowledger.h @@ -0,0 +1,54 @@ +#pragma once + +#include "ash_protocol.h" + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// Reassembles one direction of the byte stream into unstuffed, CRC-checked frames. Only +// the first ASH_HEADER_SIZE bytes of a frame are kept, as the CRC is checked as bytes +// arrive and acknowledging needs nothing past the header. +class AshFrameScanner { + public: + // Returns true when the byte completes a frame with a valid CRC + bool feed(uint8_t byte); + void reset(); + + // The first min(length(), ASH_HEADER_SIZE) bytes of the last completed frame's body. + // Valid only until the next feed() call, which begins overwriting them. + const uint8_t *header() const { return this->header_; } + size_t length() const { return this->frame_length_; } + + private: + uint8_t header_[ASH_HEADER_SIZE]; + size_t index_{0}; // body bytes read so far in the frame being read + size_t frame_length_{0}; // body length of the last completed frame + uint16_t crc_{ASH_CRC_INIT}; + bool escaped_{false}; + bool discarding_{false}; + bool poisoned_{false}; +}; + +// Works out which acknowledgements an NCP is owed while a client has handed them to this +// device. Whether to acknowledge at all is the client's decision, made by putting the port +// in PROTOCOL mode, so this only follows the NCP's frame numbering. +class AshAcknowledger { + public: + // Forgets the NCP's frame numbering. The next RSTACK or DATA frame sets it again. + void reset(); + + // Feed a byte from the NCP. Returns true when it completes a frame that is owed ack_num(). + bool feed(uint8_t byte); + uint8_t ack_num() const { return this->rx_sequence_; } + + protected: + bool handle_frame_(); + + AshFrameScanner scanner_; + uint8_t rx_sequence_{0}; + bool synced_{false}; +}; + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.cpp b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp new file mode 100644 index 0000000000..bbe9cacc2d --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.cpp @@ -0,0 +1,19 @@ +#include "ash_protocol.h" + +#include "esphome/core/helpers.h" + +namespace esphome::ezsp_proxy_tap { + +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num) { + // ACK control byte: 100nrPPP, where PPP is the next frame number expected + const uint8_t control = 0x80 | (ack_num & ASH_MAX_SEQUENCE); + const uint16_t crc = crc16be(&control, 1, ASH_CRC_INIT); + + output[0] = ASH_FLAG_BYTE; + output[1] = control; + output[2] = crc >> 8; + output[3] = crc & 0xFF; + output[4] = ASH_FLAG_BYTE; +} + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ash_protocol.h b/esphome/components/ezsp_proxy_tap/ash_protocol.h new file mode 100644 index 0000000000..4694c74501 --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ash_protocol.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace esphome::ezsp_proxy_tap { + +// ASH Protocol Constants +static constexpr uint8_t ASH_FLAG_BYTE = 0x7E; // Frame delimiter +static constexpr uint8_t ASH_ESCAPE_BYTE = 0x7D; // Escape/substitution byte +static constexpr uint8_t ASH_XOR_BYTE = 0x20; // XOR mask for escaped bytes +static constexpr uint8_t ASH_SUBSTITUTE_BYTE = 0x18; // Substitution for invalid bytes + +static constexpr uint8_t ASH_XON_BYTE = 0x11; // Resume transmission +static constexpr uint8_t ASH_XOFF_BYTE = 0x13; // Pause transmission +static constexpr uint8_t ASH_CANCEL_BYTE = 0x1A; // Discards the partial frame before it +// Sent between frames by sleepy NCPs to signal pending callbacks. No control byte is 0xFF. +static constexpr uint8_t ASH_WAKE_BYTE = 0xFF; + +static constexpr size_t ASH_MAX_DATA_FIELD_SIZE = 220; +static constexpr size_t MAX_ASH_FRAME_SIZE = 1 + ASH_MAX_DATA_FIELD_SIZE + 2; + +// The control byte, then the first Data Field byte (an RSTACK's version) +static constexpr size_t ASH_HEADER_SIZE = 2; + +// Protocol limits +static constexpr uint8_t ASH_MAX_SEQUENCE = 7; // 3-bit sequence number (0-7) + +// The frame CRC is CRC-CCITT: crc16be() with this initial value, transmitted big-endian +static constexpr uint16_t ASH_CRC_INIT = 0xFFFF; + +// FLAG, control byte, two CRC bytes, FLAG. None of the eight ACKs has a byte that needs +// escaping, so the size is fixed. +static constexpr size_t ASH_ACK_FRAME_SIZE = 5; + +// Writes the ACK frame for `ack_num` into `output` +void ash_build_ack_frame(uint8_t *output, uint8_t ack_num); + +} // namespace esphome::ezsp_proxy_tap diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp new file mode 100644 index 0000000000..c8557fcc9b --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.cpp @@ -0,0 +1,38 @@ +#include "ezsp_proxy_tap.h" + +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/core/log.h" + +namespace esphome::ezsp_proxy_tap { + +ESPHOME_LOG_TAG(TAG, "ezsp_proxy_tap"); + +void EzspProxyTap::setup() { this->parent_->set_tap(this); } + +void EzspProxyTap::dump_config() { ESP_LOGCONFIG(TAG, "EZSP Proxy Tap:\n Port: %s", this->parent_->get_name()); } + +void EzspProxyTap::on_device_rx(const uint8_t *data, size_t len) { + for (size_t i = 0; i < len; i++) { + if (this->acknowledger_.feed(data[i])) { + uint8_t frame[ASH_ACK_FRAME_SIZE]; + ash_build_ack_frame(frame, this->acknowledger_.ack_num()); + this->parent_->write_from_tap(frame, sizeof(frame)); + ESP_LOGV(TAG, "Sent ACK %u", this->acknowledger_.ack_num()); + } + } +} + +void EzspProxyTap::on_protocol_enabled() { + // Numbering from an earlier session is stale + this->acknowledger_.reset(); +} + +void EzspProxyTap::on_device_disconnected() { + // The frame numbering ended with the NCP's power; whatever boots next starts over + this->acknowledger_.reset(); +} + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h new file mode 100644 index 0000000000..2e5a444bec --- /dev/null +++ b/esphome/components/ezsp_proxy_tap/ezsp_proxy_tap.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_EZSP_PROXY_TAP + +#include "esphome/components/serial_proxy/serial_proxy.h" +#include "esphome/core/component.h" +#include "ash_acknowledger.h" + +namespace esphome::ezsp_proxy_tap { + +// Acknowledges the ASH frames of an EZSP NCP on behalf of a remote client, so the NCP's +// ack timeout is measured against this device rather than against the network round trip +// to the client. The client suppresses its own acknowledgements, making these the only +// ones the NCP sees. +// +// It never carries client traffic: the serial proxy owns the port and the bytes, and this +// component only observes them. The sole exception is the acknowledgement itself, sent +// only while a client has put the port in PROTOCOL mode, which only an EZSP client does. +class EzspProxyTap : public serial_proxy::SerialProxyTap, public Component { + public: + explicit EzspProxyTap(serial_proxy::SerialProxy *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + // SerialProxyTap + void on_device_rx(const uint8_t *data, size_t len) override; + // ACKs depend only on what the NCP sends + void on_client_tx(const uint8_t *data, size_t len) override {} + // Acknowledging is only ever useful on a client's behalf, so with nobody subscribed + // there is nothing to do and the port need not be read. + bool tap_needs_port() const override { return false; } + void on_protocol_enabled() override; + // Nothing is acknowledged outside PROTOCOL mode, and entering it starts afresh + void on_protocol_disabled() override {} + void on_device_disconnected() override; + + protected: + // The port this component observes. Owns the UART and the bytes; every write we make + // goes through it. + serial_proxy::SerialProxy *parent_; + + AshAcknowledger acknowledger_; +}; + +} // namespace esphome::ezsp_proxy_tap + +#endif // USE_EZSP_PROXY_TAP diff --git a/esphome/components/factory_reset/__init__.py b/esphome/components/factory_reset/__init__.py index a9064eb18f..e2376c217c 100644 --- a/esphome/components/factory_reset/__init__.py +++ b/esphome/components/factory_reset/__init__.py @@ -15,6 +15,7 @@ from esphome.final_validate import full_config from esphome.types import ConfigType CODEOWNERS = ["@anatoly-savchenkov"] +DOMAIN = "factory_reset" factory_reset_ns = cg.esphome_ns.namespace("factory_reset") FactoryResetComponent = factory_reset_ns.class_("FactoryResetComponent", cg.Component) diff --git a/esphome/components/factory_reset/button/factory_reset_button.cpp b/esphome/components/factory_reset/button/factory_reset_button.cpp index efce998d9d..8ef7dc67eb 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.cpp +++ b/esphome/components/factory_reset/button/factory_reset_button.cpp @@ -11,7 +11,7 @@ namespace esphome::factory_reset { -static const char *const TAG = "factory_reset.button"; +ESPHOME_LOG_TAG(TAG, "factory_reset.button"); void FactoryResetButton::dump_config() { LOG_BUTTON("", "Factory Reset Button", this); } void FactoryResetButton::press_action() { diff --git a/esphome/components/factory_reset/factory_reset.cpp b/esphome/components/factory_reset/factory_reset.cpp index bceaf6e40f..1de2e3f532 100644 --- a/esphome/components/factory_reset/factory_reset.cpp +++ b/esphome/components/factory_reset/factory_reset.cpp @@ -11,7 +11,7 @@ namespace esphome::factory_reset { -static const char *const TAG = "factory_reset"; +ESPHOME_LOG_TAG(TAG, "factory_reset"); static const uint32_t POWER_CYCLES_KEY = 0xFA5C0DE; static bool was_power_cycled() { diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.cpp b/esphome/components/factory_reset/switch/factory_reset_switch.cpp index 65404a35d5..5f38a44654 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.cpp +++ b/esphome/components/factory_reset/switch/factory_reset_switch.cpp @@ -11,7 +11,7 @@ namespace esphome::factory_reset { -static const char *const TAG = "factory_reset.switch"; +ESPHOME_LOG_TAG(TAG, "factory_reset.switch"); void FactoryResetSwitch::dump_config() { LOG_SWITCH("", "Factory Reset Switch", this); } void FactoryResetSwitch::write_state(bool state) { diff --git a/esphome/components/fan/__init__.py b/esphome/components/fan/__init__.py index d00375ae96..cb54e89c3c 100644 --- a/esphome/components/fan/__init__.py +++ b/esphome/components/fan/__init__.py @@ -38,6 +38,7 @@ from esphome.core.entity_helpers import ( setup_entity, ) +DOMAIN = "fan" IS_PLATFORM_COMPONENT = True fan_ns = cg.esphome_ns.namespace("fan") @@ -61,13 +62,8 @@ RESTORE_MODES = { } # Actions -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_( - "FanStateTrigger", automation.Trigger.template(Fan.operator("ptr")) -) FanTurnOnTrigger = fan_ns.class_("FanTurnOnTrigger", automation.Trigger.template()) FanTurnOffTrigger = fan_ns.class_("FanTurnOffTrigger", automation.Trigger.template()) FanDirectionSetTrigger = fan_ns.class_( @@ -116,11 +112,7 @@ _FAN_SCHEMA = ( cv.Optional(CONF_SPEED_COMMAND_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FanStateTrigger), - } - ), + cv.Optional(CONF_ON_STATE): automation.validate_automation(), cv.Optional(CONF_ON_TURN_ON): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FanTurnOnTrigger), @@ -222,6 +214,17 @@ def validate_preset_modes(value): return value +# The edge and set triggers keep their classes; each tracks the previous value. +_TRIGGER_AUTOMATIONS = ( + (CONF_ON_TURN_ON, []), + (CONF_ON_TURN_OFF, []), + (CONF_ON_DIRECTION_SET, [(FanDirection, "x")]), + (CONF_ON_OSCILLATING_SET, [(cg.bool_, "x")]), + (CONF_ON_SPEED_SET, [(cg.int_, "x")]), + (CONF_ON_PRESET_SET, [(cg.StringRef, "x")]), +) + + @setup_entity("fan") async def setup_fan_core_(var, config): cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) @@ -267,26 +270,10 @@ async def setup_fan_core_(var, config): await web_server.add_entity_config(var, web_server_config) for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(Fan.operator("ptr"), "x")], conf) - for conf in config.get(CONF_ON_TURN_ON, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_TURN_OFF, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_DIRECTION_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(FanDirection, "x")], conf) - for conf in config.get(CONF_ON_OSCILLATING_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.bool_, "x")], conf) - for conf in config.get(CONF_ON_SPEED_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.int_, "x")], conf) - for conf in config.get(CONF_ON_PRESET_SET, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.StringRef, "x")], conf) + await automation.build_parent_callback_automation( + var, "add_on_state_callback", (Fan.operator("ptr"), "x"), conf + ) + await automation.build_trigger_automations(var, config, _TRIGGER_AUTOMATIONS) async def register_fan(var, config): @@ -310,21 +297,8 @@ 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_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_apply_action("fan.toggle", FAN_ACTION_SCHEMA, call="toggle") +automation.register_apply_action("fan.turn_off", FAN_ACTION_SCHEMA, call="turn_off") automation.register_apply_action( "fan.turn_on", diff --git a/esphome/components/fan/automation.h b/esphome/components/fan/automation.h index 5f0843581b..e5ce09172b 100644 --- a/esphome/components/fan/automation.h +++ b/esphome/components/fan/automation.h @@ -6,24 +6,6 @@ namespace esphome::fan { -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) {} @@ -68,16 +50,6 @@ template class CycleSpeedAction final : public Action { Fan *state_; }; -class FanStateTrigger final : public Trigger { - public: - FanStateTrigger(Fan *state) : fan_(state) { - state->add_on_state_callback([this]() { this->trigger(this->fan_); }); - } - - protected: - Fan *fan_; -}; - class FanTurnOnTrigger final : public Trigger<> { public: FanTurnOnTrigger(Fan *state) : fan_(state) { diff --git a/esphome/components/fan/fan.cpp b/esphome/components/fan/fan.cpp index 65521e63d5..866f5eb0cd 100644 --- a/esphome/components/fan/fan.cpp +++ b/esphome/components/fan/fan.cpp @@ -6,7 +6,7 @@ namespace esphome::fan { -static const char *const TAG = "fan"; +ESPHOME_LOG_TAG(TAG, "fan"); // Compat: shared empty vector for getter when no preset modes are set. // Remove in 2026.11.0 when deprecated FanTraits setters are removed diff --git a/esphome/components/fastled_base/__init__.py b/esphome/components/fastled_base/__init__.py index e2fc8578cd..8936627aaa 100644 --- a/esphome/components/fastled_base/__init__.py +++ b/esphome/components/fastled_base/__init__.py @@ -12,6 +12,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@OttoWinter"] +DOMAIN = "fastled_base" fastled_base_ns = cg.esphome_ns.namespace("fastled_base") FastLEDLightOutput = fastled_base_ns.class_( "FastLEDLightOutput", light.AddressableLight diff --git a/esphome/components/fastled_base/fastled_light.cpp b/esphome/components/fastled_base/fastled_light.cpp index da4dbf2ed7..38c594a7c7 100644 --- a/esphome/components/fastled_base/fastled_light.cpp +++ b/esphome/components/fastled_base/fastled_light.cpp @@ -5,7 +5,7 @@ namespace esphome::fastled_base { -static const char *const TAG = "fastled"; +ESPHOME_LOG_TAG(TAG, "fastled"); void FastLEDLightOutput::setup() { this->controller_->init(); diff --git a/esphome/components/fastled_clockless/__init__.py b/esphome/components/fastled_clockless/__init__.py index e69de29bb2..05af618c2e 100644 --- a/esphome/components/fastled_clockless/__init__.py +++ b/esphome/components/fastled_clockless/__init__.py @@ -0,0 +1 @@ +DOMAIN = "fastled_clockless" diff --git a/esphome/components/fastled_spi/__init__.py b/esphome/components/fastled_spi/__init__.py index e69de29bb2..26451add65 100644 --- a/esphome/components/fastled_spi/__init__.py +++ b/esphome/components/fastled_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "fastled_spi" diff --git a/esphome/components/feedback/__init__.py b/esphome/components/feedback/__init__.py index 9ae2df986d..cfac77e797 100644 --- a/esphome/components/feedback/__init__.py +++ b/esphome/components/feedback/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ianchi"] +DOMAIN = "feedback" diff --git a/esphome/components/feedback/cover.py b/esphome/components/feedback/cover.py index 032d01c8e5..9fbda9a973 100644 --- a/esphome/components/feedback/cover.py +++ b/esphome/components/feedback/cover.py @@ -24,6 +24,7 @@ CONF_HAS_BUILT_IN_ENDSTOP = "has_built_in_endstop" CONF_INFER_ENDSTOP_FROM_MOVEMENT = "infer_endstop_from_movement" CONF_DIRECTION_CHANGE_WAIT_TIME = "direction_change_wait_time" CONF_ACCELERATION_WAIT_TIME = "acceleration_wait_time" +CONF_OVERSHOOT_DURATION = "overshoot_duration" CONF_OBSTACLE_ROLLBACK = "obstacle_rollback" endstop_ns = cg.esphome_ns.namespace("feedback") @@ -82,6 +83,9 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = ( cv.Optional( CONF_ACCELERATION_WAIT_TIME, "0s" ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_OVERSHOOT_DURATION, "0s" + ): cv.positive_time_period_milliseconds, cv.Optional(CONF_OBSTACLE_ROLLBACK, default="10%"): cv.percentage, }, ) @@ -89,10 +93,25 @@ CONFIG_FEEDBACK_COVER_BASE_SCHEMA = ( ) +def validate_overshoot(config: ConfigType) -> ConfigType: + if not config[CONF_OVERSHOOT_DURATION].total_milliseconds: + return config + if config[CONF_HAS_BUILT_IN_ENDSTOP]: + raise cv.Invalid( + f"{CONF_OVERSHOOT_DURATION} has no effect with {CONF_HAS_BUILT_IN_ENDSTOP}" + ) + if CONF_OPEN_ENDSTOP in config and CONF_CLOSE_ENDSTOP in config: + raise cv.Invalid( + f"{CONF_OVERSHOOT_DURATION} has no effect when both endstop sensors are supplied" + ) + return config + + CONFIG_SCHEMA = cv.All( CONFIG_FEEDBACK_COVER_BASE_SCHEMA, cv.has_none_or_all_keys(CONF_OPEN_SENSOR, CONF_CLOSE_SENSOR), validate_infer_endstop, + validate_overshoot, ) @@ -160,4 +179,7 @@ async def to_code(config: ConfigType) -> None: var.set_direction_change_waittime(config[CONF_DIRECTION_CHANGE_WAIT_TIME]) ) cg.add(var.set_acceleration_wait_time(config[CONF_ACCELERATION_WAIT_TIME])) + if (overshoot := config[CONF_OVERSHOOT_DURATION]).total_milliseconds: + cg.add_define("USE_FEEDBACK_COVER_OVERSHOOT") + cg.add(var.set_overshoot_duration(overshoot)) cg.add(var.set_obstacle_rollback(config[CONF_OBSTACLE_ROLLBACK])) diff --git a/esphome/components/feedback/feedback_cover.cpp b/esphome/components/feedback/feedback_cover.cpp index 4baffc74f8..8fa56bd370 100644 --- a/esphome/components/feedback/feedback_cover.cpp +++ b/esphome/components/feedback/feedback_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::feedback { -static const char *const TAG = "feedback.cover"; +ESPHOME_LOG_TAG(TAG, "feedback.cover"); static constexpr uint32_t DIRECTION_CHANGE_TIMEOUT_ID = 1; @@ -79,6 +79,11 @@ void FeedbackCover::dump_config() { if (this->acceleration_wait_time_) { ESP_LOGCONFIG(TAG, " Acceleration wait time: %.1fs", this->acceleration_wait_time_ / 1e3f); } +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + if (this->overshoot_duration_) { + ESP_LOGCONFIG(TAG, " Overshoot duration: %.1fs", this->overshoot_duration_ / 1e3f); + } +#endif #ifdef USE_BINARY_SENSOR if (this->obstacle_rollback_ && (this->open_obstacle_ != nullptr || this->close_obstacle_ != nullptr)) { ESP_LOGCONFIG(TAG, " Obstacle rollback: %.1f%%", this->obstacle_rollback_ * 100); @@ -234,10 +239,22 @@ void FeedbackCover::loop() { // (stoping from endstop sensor is handled in callback) if (this->current_trigger_operation_ != COVER_OPERATION_IDLE) { if (this->is_at_target_()) { - if (this->has_built_in_endstop_ && - (this->target_position_ == COVER_OPEN || this->target_position_ == COVER_CLOSED)) { + const bool at_end = this->target_position_ == COVER_OPEN || this->target_position_ == COVER_CLOSED; + if (this->has_built_in_endstop_ && at_end) { // Don't trigger stop, let the cover stop by itself. this->set_current_operation_(COVER_OPERATION_IDLE, true); +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + } else if (at_end && this->overshoot_duration_) { + // The define is global, so a cover without the option still lands here with a zero duration + // Keep driving past the end position for a while so accumulated timing error is squeezed out + if (!this->start_overshoot_time_) { + this->start_overshoot_time_ = now; + } else if (now - this->start_overshoot_time_ > this->overshoot_duration_ || + now - this->start_dir_time_ > this->max_duration_) { + ESP_LOGD(TAG, "'%s' - Overshoot duration reached. Stopping cover.", this->name_.c_str()); + this->start_direction_(COVER_OPERATION_IDLE); + } +#endif } else { this->start_direction_(COVER_OPERATION_IDLE); } @@ -340,6 +357,11 @@ void FeedbackCover::start_direction_(CoverOperation dir) { binary_sensor::BinarySensor *obstacle{nullptr}; #endif +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + // Any new command ends a running overshoot + this->start_overshoot_time_ = 0; +#endif + switch (dir) { case COVER_OPERATION_IDLE: trig = &this->stop_trigger_; diff --git a/esphome/components/feedback/feedback_cover.h b/esphome/components/feedback/feedback_cover.h index 3e4600acd2..b641f6e716 100644 --- a/esphome/components/feedback/feedback_cover.h +++ b/esphome/components/feedback/feedback_cover.h @@ -33,6 +33,9 @@ class FeedbackCover final : public cover::Cover, public Component { void set_has_built_in_endstop(bool value) { this->has_built_in_endstop_ = value; } void set_assumed_state(bool value) { this->assumed_state_ = value; } void set_max_duration(uint32_t max_duration) { this->max_duration_ = max_duration; } +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + void set_overshoot_duration(uint32_t overshoot_duration) { this->overshoot_duration_ = overshoot_duration; } +#endif void set_obstacle_rollback(float obstacle_rollback) { this->obstacle_rollback_ = obstacle_rollback; } void set_update_interval(uint32_t interval) { this->update_interval_ = interval; } void set_infer_endstop(bool infer_endstop) { this->infer_endstop_ = infer_endstop; } @@ -67,6 +70,9 @@ class FeedbackCover final : public cover::Cover, public Component { uint32_t open_duration_{0}; uint32_t close_duration_{0}; uint32_t max_duration_{UINT32_MAX}; +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + uint32_t overshoot_duration_{0}; +#endif optional direction_change_waittime_{}; uint32_t acceleration_wait_time_{0}; bool has_built_in_endstop_{false}; @@ -80,6 +86,9 @@ class FeedbackCover final : public cover::Cover, public Component { uint32_t last_recompute_time_{0}; uint32_t start_dir_time_{0}; uint32_t last_publish_time_{0}; +#ifdef USE_FEEDBACK_COVER_OVERSHOOT + uint32_t start_overshoot_time_{0}; +#endif float target_position_{0}; uint32_t update_interval_{1000}; }; diff --git a/esphome/components/file/__init__.py b/esphome/components/file/__init__.py index f70ffa9520..e98751dac5 100644 --- a/esphome/components/file/__init__.py +++ b/esphome/components/file/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "file" diff --git a/esphome/components/fingerprint_grow/__init__.py b/esphome/components/fingerprint_grow/__init__.py index 8d935a3c9e..e496e9c635 100644 --- a/esphome/components/fingerprint_grow/__init__.py +++ b/esphome/components/fingerprint_grow/__init__.py @@ -26,6 +26,7 @@ from esphome.const import ( CODEOWNERS = ["@OnFreund", "@loongyh", "@alexborro"] DEPENDENCIES = ["uart"] AUTO_LOAD = ["binary_sensor", "sensor"] +DOMAIN = "fingerprint_grow" MULTI_CONF = True CONF_FINGERPRINT_GROW_ID = "fingerprint_grow_id" @@ -37,16 +38,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 = { @@ -90,8 +81,8 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_IDLE_PERIOD_TO_SLEEP ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_PASSWORD): cv.uint32_t, - cv.Optional(CONF_NEW_PASSWORD): cv.uint32_t, + cv.Optional(CONF_PASSWORD): cv.sensitive(cv.uint32_t), + cv.Optional(CONF_NEW_PASSWORD): cv.sensitive(cv.uint32_t), cv.Optional(CONF_ON_FINGER_SCAN_START): automation.validate_automation({}), cv.Optional(CONF_ON_FINGER_SCAN_MATCHED): automation.validate_automation( {} @@ -181,111 +172,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 +243,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.cpp b/esphome/components/fingerprint_grow/fingerprint_grow.cpp index 07630f121a..99e88af5b7 100644 --- a/esphome/components/fingerprint_grow/fingerprint_grow.cpp +++ b/esphome/components/fingerprint_grow/fingerprint_grow.cpp @@ -5,7 +5,7 @@ namespace esphome::fingerprint_grow { -static const char *const TAG = "fingerprint_grow"; +ESPHOME_LOG_TAG(TAG, "fingerprint_grow"); // Based on Adafruit's library: https://github.com/adafruit/Adafruit-Fingerprint-Sensor-Library 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..8f7ad6e71f 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -3,9 +3,10 @@ #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"; +ESPHOME_LOG_TAG(TAG, "font"); #ifdef USE_LVGL_FONT static const uint8_t OPA4_TABLE[16] = {0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187, 204, 221, 238, 255}; @@ -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/fs3000/__init__.py b/esphome/components/fs3000/__init__.py index e69de29bb2..c1a6b39d9b 100644 --- a/esphome/components/fs3000/__init__.py +++ b/esphome/components/fs3000/__init__.py @@ -0,0 +1 @@ +DOMAIN = "fs3000" diff --git a/esphome/components/fs3000/fs3000.cpp b/esphome/components/fs3000/fs3000.cpp index a89b166182..b5e1c98bcb 100644 --- a/esphome/components/fs3000/fs3000.cpp +++ b/esphome/components/fs3000/fs3000.cpp @@ -3,7 +3,7 @@ namespace esphome::fs3000 { -static const char *const TAG = "fs3000"; +ESPHOME_LOG_TAG(TAG, "fs3000"); void FS3000Component::setup() { if (model_ == FIVE) { @@ -42,12 +42,7 @@ void FS3000Component::update() { } // checksum passes if the modulo 256 sum of the five bytes is 0 - uint8_t checksum = 0; - for (uint8_t i : data) { - checksum += i; - } - - if (checksum != 0) { + if (sum_bytes(data, sizeof(data)) != 0) { this->status_set_warning(); ESP_LOGW(TAG, "Checksum failure when reading from FS3000"); return; diff --git a/esphome/components/ft5x06/__init__.py b/esphome/components/ft5x06/__init__.py index dceea71dd0..9770f30e43 100644 --- a/esphome/components/ft5x06/__init__.py +++ b/esphome/components/ft5x06/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "ft5x06" ft5x06_ns = cg.esphome_ns.namespace("ft5x06") diff --git a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp index 24d3529fb4..ac5f4d8933 100644 --- a/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp +++ b/esphome/components/ft5x06/touchscreen/ft5x06_touchscreen.cpp @@ -5,7 +5,7 @@ namespace esphome::ft5x06 { -static const char *const TAG = "ft5x06.touchscreen"; +ESPHOME_LOG_TAG(TAG, "ft5x06.touchscreen"); void FT5x06Touchscreen::setup() { if (this->interrupt_pin_ != nullptr) { diff --git a/esphome/components/ft63x6/__init__.py b/esphome/components/ft63x6/__init__.py index b6d7d3580e..565da54fe1 100644 --- a/esphome/components/ft63x6/__init__.py +++ b/esphome/components/ft63x6/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@gpambrozio"] +DOMAIN = "ft63x6" diff --git a/esphome/components/ft63x6/ft63x6.cpp b/esphome/components/ft63x6/ft63x6.cpp index dd8f5ba629..85729c17f6 100644 --- a/esphome/components/ft63x6/ft63x6.cpp +++ b/esphome/components/ft63x6/ft63x6.cpp @@ -25,7 +25,7 @@ static const uint8_t FT6X36_ADDR_THRESHHOLD = 0x80; static const uint8_t FT6X36_ADDR_TOUCHRATE_ACTIVE = 0x88; static const uint8_t FT63X6_ADDR_CHIP_ID = 0xA3; -static const char *const TAG = "FT63X6"; +ESPHOME_LOG_TAG(TAG, "FT63X6"); void FT63X6Touchscreen::setup() { if (this->interrupt_pin_ != nullptr) { diff --git a/esphome/components/fujitsu_general/__init__.py b/esphome/components/fujitsu_general/__init__.py index e69de29bb2..fdb21576be 100644 --- a/esphome/components/fujitsu_general/__init__.py +++ b/esphome/components/fujitsu_general/__init__.py @@ -0,0 +1 @@ +DOMAIN = "fujitsu_general" diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index 9995a3abbd..af411d133a 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -2,7 +2,7 @@ namespace esphome::fujitsu_general { -static const char *const TAG = "fujitsu_general.climate"; +ESPHOME_LOG_TAG(TAG, "fujitsu_general.climate"); // Common header constexpr uint8_t FUJITSU_GENERAL_COMMON_LENGTH = 6; @@ -224,11 +224,7 @@ void FujitsuGeneralClimate::transmit_(uint8_t const *message, uint8_t length) { } uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { - uint8_t checksum = 0; - for (uint8_t i = 7; i < FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 1; ++i) { - checksum += message[i]; - } - return 256 - checksum; + return -sum_bytes(message + 7, FUJITSU_GENERAL_STATE_MESSAGE_LENGTH - 8); } uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } diff --git a/esphome/components/gcja5/__init__.py b/esphome/components/gcja5/__init__.py index 122ffaf6ed..9df2024e2a 100644 --- a/esphome/components/gcja5/__init__.py +++ b/esphome/components/gcja5/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@gcormier"] +DOMAIN = "gcja5" diff --git a/esphome/components/gcja5/gcja5.cpp b/esphome/components/gcja5/gcja5.cpp index e84c51bde8..fcb9e832ea 100644 --- a/esphome/components/gcja5/gcja5.cpp +++ b/esphome/components/gcja5/gcja5.cpp @@ -11,7 +11,7 @@ namespace esphome::gcja5 { -static const char *const TAG = "gcja5"; +ESPHOME_LOG_TAG(TAG, "gcja5"); void GCJA5Component::loop() { const uint32_t now = App.get_loop_component_start_time(); diff --git a/esphome/components/gdk101/__init__.py b/esphome/components/gdk101/__init__.py index f98af3f863..e0ede6136d 100644 --- a/esphome/components/gdk101/__init__.py +++ b/esphome/components/gdk101/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Szewcson"] +DOMAIN = "gdk101" DEPENDENCIES = ["i2c"] MULTI_CONF = True diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index bc0ef70578..3c7e8d2161 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -4,7 +4,7 @@ namespace esphome::gdk101 { -static const char *const TAG = "gdk101"; +ESPHOME_LOG_TAG(TAG, "gdk101"); static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5; static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 30; static constexpr uint32_t RESET_INTERVAL_ID = 0; 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/gl_r01_i2c/__init__.py b/esphome/components/gl_r01_i2c/__init__.py index e69de29bb2..23c5e19b18 100644 --- a/esphome/components/gl_r01_i2c/__init__.py +++ b/esphome/components/gl_r01_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "gl_r01_i2c" diff --git a/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp b/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp index 75f0c69a7b..6e2844eb9e 100644 --- a/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp +++ b/esphome/components/gl_r01_i2c/gl_r01_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::gl_r01_i2c { -static const char *const TAG = "gl_r01_i2c"; +ESPHOME_LOG_TAG(TAG, "gl_r01_i2c"); // Register definitions from datasheet static const uint8_t REG_VERSION = 0x00; diff --git a/esphome/components/globals/__init__.py b/esphome/components/globals/__init__.py index 8adf4e983c..a35e228045 100644 --- a/esphome/components/globals/__init__.py +++ b/esphome/components/globals/__init__.py @@ -12,6 +12,7 @@ from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "globals" globals_ns = cg.esphome_ns.namespace("globals") GlobalsComponent = globals_ns.class_("GlobalsComponent", cg.Component) RestoringGlobalsComponent = globals_ns.class_( diff --git a/esphome/components/gp2y1010au0f/__init__.py b/esphome/components/gp2y1010au0f/__init__.py index e69de29bb2..36c643103c 100644 --- a/esphome/components/gp2y1010au0f/__init__.py +++ b/esphome/components/gp2y1010au0f/__init__.py @@ -0,0 +1 @@ +DOMAIN = "gp2y1010au0f" diff --git a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp index 0dd4a13a21..ac98a01fd5 100644 --- a/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp +++ b/esphome/components/gp2y1010au0f/gp2y1010au0f.cpp @@ -6,7 +6,10 @@ namespace esphome::gp2y1010au0f { -static const char *const TAG = "gp2y1010au0f"; +ESPHOME_LOG_TAG(TAG, "gp2y1010au0f"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; + static const float MIN_VOLTAGE = 0.0f; static const float MAX_VOLTAGE = 4.0f; @@ -22,7 +25,7 @@ void GP2Y1010AU0FSensor::dump_config() { void GP2Y1010AU0FSensor::update() { is_sampling_ = true; - this->set_timeout("read", this->sample_duration_, [this]() { + this->set_timeout(READ_TIMEOUT_ID, this->sample_duration_, [this]() { this->is_sampling_ = false; if (this->num_samples_ == 0) return; diff --git a/esphome/components/gp8403/__init__.py b/esphome/components/gp8403/__init__.py index 17c88b6875..583b1d1568 100644 --- a/esphome/components/gp8403/__init__.py +++ b/esphome/components/gp8403/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@sebydocky"] DEPENDENCIES = ["i2c"] +DOMAIN = "gp8403" MULTI_CONF = True gp8403_ns = cg.esphome_ns.namespace("gp8403") diff --git a/esphome/components/gp8403/gp8403.cpp b/esphome/components/gp8403/gp8403.cpp index 7e93bb6295..b82c32c053 100644 --- a/esphome/components/gp8403/gp8403.cpp +++ b/esphome/components/gp8403/gp8403.cpp @@ -4,7 +4,7 @@ namespace esphome::gp8403 { -static const char *const TAG = "gp8403"; +ESPHOME_LOG_TAG(TAG, "gp8403"); static const uint8_t RANGE_REGISTER = 0x01; static const uint8_t OUTPUT_REGISTER = 0x02; diff --git a/esphome/components/gp8403/output/gp8403_output.cpp b/esphome/components/gp8403/output/gp8403_output.cpp index 7a22a280ac..92872f72f2 100644 --- a/esphome/components/gp8403/output/gp8403_output.cpp +++ b/esphome/components/gp8403/output/gp8403_output.cpp @@ -4,7 +4,7 @@ namespace esphome::gp8403 { -static const char *const TAG = "gp8403.output"; +ESPHOME_LOG_TAG(TAG, "gp8403.output"); void GP8403Output::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/gpio/__init__.py b/esphome/components/gpio/__init__.py index 07ebb64cd2..e529b2276d 100644 --- a/esphome/components/gpio/__init__.py +++ b/esphome/components/gpio/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg CODEOWNERS = ["@esphome/core"] +DOMAIN = "gpio" gpio_ns = cg.esphome_ns.namespace("gpio") diff --git a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp index 9d044dca2d..87985f189c 100644 --- a/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp +++ b/esphome/components/gpio/binary_sensor/gpio_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::gpio { -static const char *const TAG = "gpio.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "gpio.binary_sensor"); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG #ifdef USE_GPIO_BINARY_SENSOR_INTERRUPT diff --git a/esphome/components/gpio/one_wire/gpio_one_wire.cpp b/esphome/components/gpio/one_wire/gpio_one_wire.cpp index f445efeca3..62b197d13c 100644 --- a/esphome/components/gpio/one_wire/gpio_one_wire.cpp +++ b/esphome/components/gpio/one_wire/gpio_one_wire.cpp @@ -4,7 +4,7 @@ namespace esphome::gpio { -static const char *const TAG = "gpio.one_wire"; +ESPHOME_LOG_TAG(TAG, "gpio.one_wire"); void GPIOOneWireBus::setup() { this->t_pin_->setup(); diff --git a/esphome/components/gpio/output/__init__.py b/esphome/components/gpio/output/__init__.py index ab242c643f..645dd61f80 100644 --- a/esphome/components/gpio/output/__init__.py +++ b/esphome/components/gpio/output/__init__.py @@ -1,8 +1,10 @@ from esphome import pins import esphome.codegen as cg from esphome.components import output +from esphome.components.const import CONF_HOLD_STATE import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_PIN +from esphome.const import CONF_ID, CONF_PIN, CONF_POWER_SUPPLY +import esphome.final_validate as fv from esphome.types import ConfigType from .. import gpio_ns @@ -17,6 +19,27 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend( ).extend(cv.COMPONENT_SCHEMA) +def _require_hold(ps_config: ConfigType) -> ConfigType: + if not ps_config[CONF_PIN].get(CONF_HOLD_STATE): + raise cv.Invalid( + f"The power supply pin must also set {CONF_HOLD_STATE} " + f"when an output it powers sets it", + [CONF_PIN], + ) + return ps_config + + +def _final_validate(config: ConfigType) -> None: + if not config[CONF_PIN].get(CONF_HOLD_STATE): + return + if (ps_id := config.get(CONF_POWER_SUPPLY)) is None: + return + fv.id_declaration_match_schema(_require_hold)(ps_id) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await output.register_output(var, config) diff --git a/esphome/components/gpio/output/gpio_binary_output.cpp b/esphome/components/gpio/output/gpio_binary_output.cpp index 402ad03c49..b407ea3f4b 100644 --- a/esphome/components/gpio/output/gpio_binary_output.cpp +++ b/esphome/components/gpio/output/gpio_binary_output.cpp @@ -3,7 +3,7 @@ namespace esphome::gpio { -static const char *const TAG = "gpio.output"; +ESPHOME_LOG_TAG(TAG, "gpio.output"); void GPIOBinaryOutput::dump_config() { ESP_LOGCONFIG(TAG, "Binary Output:"); diff --git a/esphome/components/gpio/output/gpio_binary_output.h b/esphome/components/gpio/output/gpio_binary_output.h index 496afd131b..06e3647482 100644 --- a/esphome/components/gpio/output/gpio_binary_output.h +++ b/esphome/components/gpio/output/gpio_binary_output.h @@ -11,9 +11,12 @@ class GPIOBinaryOutput final : public output::BinaryOutput, public Component { void set_pin(GPIOPin *pin) { pin_ = pin; } void setup() override { - this->turn_off(); + const bool held = this->pin_->is_held(); + if (!held) + this->turn_off(); this->pin_->setup(); - this->turn_off(); + if (!held) + this->turn_off(); } void dump_config() override; float get_setup_priority() const override { return setup_priority::HARDWARE; } diff --git a/esphome/components/gpio/switch/__init__.py b/esphome/components/gpio/switch/__init__.py index 766cdc4afb..481b8d01da 100644 --- a/esphome/components/gpio/switch/__init__.py +++ b/esphome/components/gpio/switch/__init__.py @@ -36,11 +36,17 @@ async def to_code(config: ConfigType) -> None: pin = await cg.gpio_pin_expression(config[CONF_PIN]) cg.add(var.set_pin(pin)) - if CONF_INTERLOCK in config: + if (interlock := config.get(CONF_INTERLOCK)) is not None: cg.add_define("USE_GPIO_SWITCH_INTERLOCK") - interlock = [] - for it in config[CONF_INTERLOCK]: - lock = await cg.get_variable(it) - interlock.append(lock) - cg.add(var.set_interlock(interlock)) + locks = [await cg.get_variable(it) for it in interlock] + # The group's table lists this switch too, so every member shares one table. + group = sorted({str(s): s for s in (var, *locks)}.values(), key=str) + if len(group) > 1: + table = cg.shared_progmem_array( + "gpio_interlock", + switch.Switch.operator("ptr"), + cg.ArrayInitializer(*group), + constexpr=False, + ) + cg.add(var.set_interlock(table, len(group))) cg.add(var.set_interlock_wait_time(config[CONF_INTERLOCK_WAIT_TIME])) diff --git a/esphome/components/gpio/switch/gpio_switch.cpp b/esphome/components/gpio/switch/gpio_switch.cpp index d231b3d77a..68086d9466 100644 --- a/esphome/components/gpio/switch/gpio_switch.cpp +++ b/esphome/components/gpio/switch/gpio_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::gpio { -static const char *const TAG = "switch.gpio"; +ESPHOME_LOG_TAG(TAG, "switch.gpio"); #ifdef USE_GPIO_SWITCH_INTERLOCK static constexpr uint32_t INTERLOCK_TIMEOUT_ID = 0; #endif @@ -12,8 +12,9 @@ float GPIOSwitch::get_setup_priority() const { return setup_priority::HARDWARE; void GPIOSwitch::setup() { bool initial_state = this->get_initial_state_with_restore_mode().value_or(false); - // write state before setup - this->control(initial_state); + // write state before setup unless the pin kept it across the reset + if (!this->pin_->is_held()) + this->control(initial_state); this->pin_->setup(); // write after setup again for other IOs this->control(initial_state); @@ -66,8 +67,4 @@ void GPIOSwitch::write_state(bool state) { this->publish_state(state); } -#ifdef USE_GPIO_SWITCH_INTERLOCK -void GPIOSwitch::set_interlock(const std::initializer_list &interlock) { this->interlock_ = interlock; } -#endif - } // namespace esphome::gpio diff --git a/esphome/components/gpio/switch/gpio_switch.h b/esphome/components/gpio/switch/gpio_switch.h index e7323e6e93..64e4f95f16 100644 --- a/esphome/components/gpio/switch/gpio_switch.h +++ b/esphome/components/gpio/switch/gpio_switch.h @@ -21,7 +21,10 @@ class GPIOSwitch final : public switch_::Switch, public Component { void setup() override; void dump_config() override; #ifdef USE_GPIO_SWITCH_INTERLOCK - void set_interlock(const std::initializer_list &interlock); + /// Codegen only: the table must outlive this switch; it may list this switch, which is skipped. + void set_interlock(Switch *const *interlock, size_t count) { + this->interlock_ = ConstVector(interlock, count); + } void set_interlock_wait_time(uint32_t interlock_wait_time) { interlock_wait_time_ = interlock_wait_time; } #endif @@ -30,7 +33,7 @@ class GPIOSwitch final : public switch_::Switch, public Component { GPIOPin *pin_{nullptr}; #ifdef USE_GPIO_SWITCH_INTERLOCK - FixedVector interlock_; + ConstVector interlock_; uint32_t interlock_wait_time_{0}; #endif }; diff --git a/esphome/components/gpio_expander/__init__.py b/esphome/components/gpio_expander/__init__.py index 0c7199b6df..cf6be6e8e0 100644 --- a/esphome/components/gpio_expander/__init__.py +++ b/esphome/components/gpio_expander/__init__.py @@ -3,6 +3,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ALLOW_OTHER_USES, CONF_INTERRUPT_PIN, CONF_INVERTED from esphome.types import ConfigType +DOMAIN = "gpio_expander" + def validate_interrupt_pin(value: ConfigType) -> ConfigType: # The expander components own INT polarity (active-low, hardcoded falling-edge ISR) diff --git a/esphome/components/gps/__init__.py b/esphome/components/gps/__init__.py index 94a36a0afa..cc77dff496 100644 --- a/esphome/components/gps/__init__.py +++ b/esphome/components/gps/__init__.py @@ -33,6 +33,7 @@ DEPENDENCIES = ["uart"] AUTO_LOAD = ["sensor"] CODEOWNERS = ["@coogle", "@ximex"] +DOMAIN = "gps" gps_ns = cg.esphome_ns.namespace("gps") GPS = gps_ns.class_("GPS", cg.PollingComponent, uart.UARTDevice) diff --git a/esphome/components/gps/gps.cpp b/esphome/components/gps/gps.cpp index 4b8abd219b..ec54ac9748 100644 --- a/esphome/components/gps/gps.cpp +++ b/esphome/components/gps/gps.cpp @@ -3,7 +3,7 @@ namespace esphome::gps { -static const char *const TAG = "gps"; +ESPHOME_LOG_TAG(TAG, "gps"); TinyGPSPlus &GPSListener::get_tiny_gps() { return this->parent_->get_tiny_gps(); } diff --git a/esphome/components/gps/time/gps_time.cpp b/esphome/components/gps/time/gps_time.cpp index 3859983ceb..958d0645d9 100644 --- a/esphome/components/gps/time/gps_time.cpp +++ b/esphome/components/gps/time/gps_time.cpp @@ -3,7 +3,7 @@ namespace esphome::gps { -static const char *const TAG = "gps.time"; +ESPHOME_LOG_TAG(TAG, "gps.time"); void GPSTime::from_tiny_gps_(TinyGPSPlus &tiny_gps) { if (!tiny_gps.time.isValid() || !tiny_gps.date.isValid() || !tiny_gps.time.isUpdated() || diff --git a/esphome/components/graph/__init__.py b/esphome/components/graph/__init__.py index 1b99491f9c..0aee597334 100644 --- a/esphome/components/graph/__init__.py +++ b/esphome/components/graph/__init__.py @@ -32,6 +32,7 @@ from esphome.const import ( from esphome.types import ConfigType CODEOWNERS = ["@synco"] +DOMAIN = "graph" DEPENDENCIES = ["display", "sensor"] MULTI_CONF = True diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 9ceb2f2ba0..e09eb385e3 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -9,8 +9,8 @@ namespace esphome::graph { using namespace display; -static const char *const TAG = "graph"; -static const char *const TAGL = "graphlegend"; +ESPHOME_LOG_TAG(TAG, "graph"); +ESPHOME_LOG_TAG(TAGL, "graphlegend"); void HistoryData::init(int length) { this->length_ = length; diff --git a/esphome/components/graphical_display_menu/__init__.py b/esphome/components/graphical_display_menu/__init__.py index 668a0d74d1..a398aea619 100644 --- a/esphome/components/graphical_display_menu/__init__.py +++ b/esphome/components/graphical_display_menu/__init__.py @@ -13,7 +13,6 @@ from esphome.const import ( CONF_FONT, CONF_FOREGROUND_COLOR, CONF_ID, - CONF_TRIGGER_ID, ) from esphome.types import ConfigType @@ -29,11 +28,9 @@ MenuItemValueArguments = graphical_display_menu_ns.struct("MenuItemValueArgument MenuItemValueArgumentsConstPtr = MenuItemValueArguments.operator("ptr").operator( "const" ) -GraphicalDisplayMenuOnRedrawTrigger = graphical_display_menu_ns.class_( - "GraphicalDisplayMenuOnRedrawTrigger", automation.Trigger -) CODEOWNERS = ["@MrMDavidson"] +DOMAIN = "graphical_display_menu" AUTO_LOAD = ["display_menu_base"] @@ -48,13 +45,7 @@ CONFIG_SCHEMA = DISPLAY_MENU_BASE_SCHEMA.extend( cv.Optional(CONF_MENU_ITEM_VALUE): cv.templatable(cv.string), cv.Optional(CONF_FOREGROUND_COLOR): cv.use_id(color.ColorStruct), cv.Optional(CONF_BACKGROUND_COLOR): cv.use_id(color.ColorStruct), - cv.Optional(CONF_ON_REDRAW): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - GraphicalDisplayMenuOnRedrawTrigger - ) - } - ), + cv.Optional(CONF_ON_REDRAW): automation.validate_automation({}), } ) ) @@ -91,9 +82,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_background_color(background_color)) for conf in config.get(CONF_ON_REDRAW, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(GraphicalDisplayMenuConstPtr, "it")], conf + await automation.build_callback_automation( + var, "add_on_redraw_callback", [(GraphicalDisplayMenuConstPtr, "it")], conf ) await display_menu_to_code(var, config) diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.cpp b/esphome/components/graphical_display_menu/graphical_display_menu.cpp index d261c48855..5f5b3e8d42 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.cpp +++ b/esphome/components/graphical_display_menu/graphical_display_menu.cpp @@ -7,7 +7,7 @@ namespace esphome::graphical_display_menu { -static const char *const TAG = "graphical_display_menu"; +ESPHOME_LOG_TAG(TAG, "graphical_display_menu"); void GraphicalDisplayMenu::setup() { if (this->display_ != nullptr) { @@ -241,6 +241,6 @@ void GraphicalDisplayMenu::draw_item(const display_menu_base::MenuItem *item, co "draw_item should be called."); } -void GraphicalDisplayMenu::update() { this->on_redraw_callbacks_.call(); } +void GraphicalDisplayMenu::update() { this->on_redraw_callbacks_.call(this); } } // namespace esphome::graphical_display_menu diff --git a/esphome/components/graphical_display_menu/graphical_display_menu.h b/esphome/components/graphical_display_menu/graphical_display_menu.h index 13c0f9d73f..1f1d8ff3fc 100644 --- a/esphome/components/graphical_display_menu/graphical_display_menu.h +++ b/esphome/components/graphical_display_menu/graphical_display_menu.h @@ -70,17 +70,7 @@ class GraphicalDisplayMenu final : public display_menu_base::DisplayMenuComponen Color foreground_color_{COLOR_ON}; Color background_color_{COLOR_OFF}; - CallbackManager on_redraw_callbacks_{}; -}; - -class GraphicalDisplayMenuOnRedrawTrigger final : public Trigger { - public: - explicit GraphicalDisplayMenuOnRedrawTrigger(GraphicalDisplayMenu *parent) : parent_(parent) { - parent->add_on_redraw_callback([this]() { this->trigger(this->parent_); }); - } - - protected: - GraphicalDisplayMenu *parent_; + LazyCallbackManager on_redraw_callbacks_{}; }; } // namespace graphical_display_menu diff --git a/esphome/components/gree/__init__.py b/esphome/components/gree/__init__.py index 2dd9ac0f1c..67489b3876 100644 --- a/esphome/components/gree/__init__.py +++ b/esphome/components/gree/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "gree" + gree_ns = cg.esphome_ns.namespace("gree") diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index 6add618ecb..0e02f20080 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -3,7 +3,7 @@ namespace esphome::gree { -static const char *const TAG = "gree.climate"; +ESPHOME_LOG_TAG(TAG, "gree.climate"); void GreeClimate::set_model(Model model) { if (model == GREE_YAN) { @@ -37,7 +37,6 @@ void GreeClimate::transmit_state() { remote_state[1] = this->temperature_(); 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_(); } @@ -61,7 +60,6 @@ void GreeClimate::transmit_state() { } 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 @@ -226,7 +224,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 c261629573..a85878e764 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -76,6 +76,12 @@ static constexpr uint8_t GREE_PRESET_NONE = 0x00; static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +// Mode bits stored in remote_state[2] bits 4..7 for YAN/YAA/YAC/YAC1FB9. +static constexpr uint8_t GREE_MODE_BIT_TURBO = 0x10; +static constexpr uint8_t GREE_MODE_BIT_LIGHT = 0x20; +static constexpr uint8_t GREE_MODE_BIT_HEALTH = 0x40; +static constexpr uint8_t GREE_MODE_BIT_XFAN = 0x80; + // Model codes enum Model : uint8_t { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -103,7 +109,7 @@ class GreeClimate final : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; - uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] + uint8_t mode_bits_{GREE_MODE_BIT_LIGHT}; // The display light is on until a switch says otherwise }; } // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py index 9bec3751d6..b48d32b900 100644 --- a/esphome/components/gree/switch/__init__.py +++ b/esphome/components/gree/switch/__init__.py @@ -17,12 +17,22 @@ CONF_HEALTH = "health" CONF_XFAN = "xfan" CONF_GREE_ID = "gree_id" -# Switch configurations: (config_key, display_name, bit_mask, icon) +# Switch configurations: (config_key, display_name, mode_bit, icon) SWITCH_CONFIGS = ( - (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), - (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), - (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), - (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), + ( + CONF_TURBO, + "Gree Turbo Switch", + gree_ns.GREE_MODE_BIT_TURBO, + "mdi:car-turbocharger", + ), + (CONF_LIGHT, "Gree Light Switch", gree_ns.GREE_MODE_BIT_LIGHT, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", gree_ns.GREE_MODE_BIT_HEALTH, "mdi:pine-tree"), + ( + CONF_XFAN, + "Gree X-FAN Switch", + gree_ns.GREE_MODE_BIT_XFAN, + "mdi:wall-sconce-flat", + ), ) SUPPORTED_MODELS = { @@ -39,7 +49,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(key): switch.switch_schema( GreeModeBitSwitch, icon=icon, - default_restore_mode="RESTORE_DEFAULT_OFF", + # The display light was on before the switches existed, so it stays on by default + default_restore_mode="RESTORE_DEFAULT_ON" + if key == CONF_LIGHT + else "RESTORE_DEFAULT_OFF", device_class=DEVICE_CLASS_SWITCH, entity_category=ENTITY_CATEGORY_CONFIG, ) diff --git a/esphome/components/gree/switch/gree_switch.cpp b/esphome/components/gree/switch/gree_switch.cpp index 2f649733af..0d8f769580 100644 --- a/esphome/components/gree/switch/gree_switch.cpp +++ b/esphome/components/gree/switch/gree_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::gree { -static const char *const TAG = "gree.switch"; +ESPHOME_LOG_TAG(TAG, "gree.switch"); void GreeModeBitSwitch::setup() { auto initial = this->get_initial_state_with_restore_mode(); diff --git a/esphome/components/grove_gas_mc_v2/__init__.py b/esphome/components/grove_gas_mc_v2/__init__.py index e69de29bb2..31a956ac6f 100644 --- a/esphome/components/grove_gas_mc_v2/__init__.py +++ b/esphome/components/grove_gas_mc_v2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "grove_gas_mc_v2" diff --git a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp index 0de0b02182..b01dd119e6 100644 --- a/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp +++ b/esphome/components/grove_gas_mc_v2/grove_gas_mc_v2.cpp @@ -4,7 +4,7 @@ namespace esphome::grove_gas_mc_v2 { -static const char *const TAG = "grove_gas_mc_v2"; +ESPHOME_LOG_TAG(TAG, "grove_gas_mc_v2"); // I2C Commands for Grove Gas Multichannel V2 Sensor // Taken from: diff --git a/esphome/components/grove_tb6612fng/__init__.py b/esphome/components/grove_tb6612fng/__init__.py index ae64c049f5..0874613c5c 100644 --- a/esphome/components/grove_tb6612fng/__init__.py +++ b/esphome/components/grove_tb6612fng/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( DEPENDENCIES = ["i2c"] CODEOWNERS = ["@max246"] +DOMAIN = "grove_tb6612fng" MULTI_CONF = True @@ -20,28 +21,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 +43,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 +53,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.cpp b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp index 2c68eef623..80de3451d7 100644 --- a/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp +++ b/esphome/components/grove_tb6612fng/grove_tb6612fng.cpp @@ -4,7 +4,7 @@ namespace esphome::grove_tb6612fng { -static const char *const TAG = "GroveMotorDriveTB6612FNG"; +ESPHOME_LOG_TAG(TAG, "GroveMotorDriveTB6612FNG"); static const uint8_t GROVE_MOTOR_DRIVER_I2C_CMD_BRAKE = 0x00; static const uint8_t GROVE_MOTOR_DRIVER_I2C_CMD_STOP = 0x01; 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/__init__.py b/esphome/components/growatt_solar/__init__.py index e69de29bb2..078e836783 100644 --- a/esphome/components/growatt_solar/__init__.py +++ b/esphome/components/growatt_solar/__init__.py @@ -0,0 +1 @@ +DOMAIN = "growatt_solar" diff --git a/esphome/components/growatt_solar/growatt_solar.cpp b/esphome/components/growatt_solar/growatt_solar.cpp index 08c3966ed9..5c079b42d9 100644 --- a/esphome/components/growatt_solar/growatt_solar.cpp +++ b/esphome/components/growatt_solar/growatt_solar.cpp @@ -5,7 +5,7 @@ namespace esphome::growatt_solar { namespace helpers = modbus::helpers; -static const char *const TAG = "growatt_solar"; +ESPHOME_LOG_TAG(TAG, "growatt_solar"); static const uint8_t MODBUS_REGISTER_COUNT[] = {33, 95}; // indexed with enum GrowattProtocolVersion diff --git a/esphome/components/growatt_solar/sensor.py b/esphome/components/growatt_solar/sensor.py index 3728710b1f..af323e6b07 100644 --- a/esphome/components/growatt_solar/sensor.py +++ b/esphome/components/growatt_solar/sensor.py @@ -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/gsl3670/__init__.py b/esphome/components/gsl3670/__init__.py index c58ce8a01e..24d19ee887 100644 --- a/esphome/components/gsl3670/__init__.py +++ b/esphome/components/gsl3670/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "gsl3670" diff --git a/esphome/components/gsl3670/gsl3670_touchscreen.cpp b/esphome/components/gsl3670/gsl3670_touchscreen.cpp index 9115130f4a..e518368071 100644 --- a/esphome/components/gsl3670/gsl3670_touchscreen.cpp +++ b/esphome/components/gsl3670/gsl3670_touchscreen.cpp @@ -4,7 +4,7 @@ namespace esphome::gsl3670 { -static const char *const TAG = "gsl3670.touchscreen"; +ESPHOME_LOG_TAG(TAG, "gsl3670.touchscreen"); static const size_t MAX_TOUCHES = 3; // --------------------------------------------------------------------------- // setup() – mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP: diff --git a/esphome/components/gt911/__init__.py b/esphome/components/gt911/__init__.py index 1f7ecd1d5e..2b45ba5efe 100644 --- a/esphome/components/gt911/__init__.py +++ b/esphome/components/gt911/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@jesserockz", "@clydebarrow"] DEPENDENCIES = ["i2c"] +DOMAIN = "gt911" gt911_ns = cg.esphome_ns.namespace("gt911") diff --git a/esphome/components/gt911/binary_sensor/gt911_button.cpp b/esphome/components/gt911/binary_sensor/gt911_button.cpp index 7b356f3946..2a3fc631d6 100644 --- a/esphome/components/gt911/binary_sensor/gt911_button.cpp +++ b/esphome/components/gt911/binary_sensor/gt911_button.cpp @@ -3,7 +3,7 @@ namespace esphome::gt911 { -static const char *const TAG = "GT911.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "GT911.binary_sensor"); void GT911Button::setup() { this->parent_->register_button_listener(this); diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 8ced267947..07dd325a2c 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -6,7 +6,7 @@ namespace esphome::gt911 { -static const char *const TAG = "gt911.touchscreen"; +ESPHOME_LOG_TAG(TAG, "gt911.touchscreen"); static const uint8_t PRIMARY_ADDRESS = 0x5D; // default I2C address for GT911 static const uint8_t SECONDARY_ADDRESS = 0x14; // secondary I2C address for GT911 diff --git a/esphome/components/haier/__init__.py b/esphome/components/haier/__init__.py index e69de29bb2..605d315185 100644 --- a/esphome/components/haier/__init__.py +++ b/esphome/components/haier/__init__.py @@ -0,0 +1 @@ +DOMAIN = "haier" 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..db915e9c7f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -4,14 +4,14 @@ from esphome import automation import esphome.codegen as cg from esphome.components import climate, logger, uart from esphome.components.climate import ClimateMode, ClimatePreset, ClimateSwingMode +from esphome.components.logger import DOMAIN as LOGGER_DOMAIN +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BEEPER, CONF_CURRENT_TEMPERATURE, CONF_DISPLAY, - CONF_ID, CONF_LEVEL, - CONF_LOGGER, CONF_LOGS, CONF_MAX_TEMPERATURE, CONF_MIN_TEMPERATURE, @@ -23,7 +23,6 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE, CONF_TEMPERATURE_STEP, CONF_VISUAL, - CONF_WIFI, ) from esphome.cpp_generator import MockObjClass import esphome.final_validate as fv @@ -187,7 +186,12 @@ def _base_config_schema(class_: MockObjClass) -> cv.Schema: cv.Optional( CONF_ANSWER_TIMEOUT, ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_STATUS_MESSAGE): automation.validate_automation({}), + # data points into the protocol's receive buffer, reused for the next frame + cv.Optional( + CONF_ON_STATUS_MESSAGE + ): automation.validate_synchronous_automation( + "on_status_message", "the received frame" + ), } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -254,23 +258,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 +271,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 +308,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,63 +323,17 @@ 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: full_config = fv.full_config.get() - if CONF_LOGGER in full_config: + if LOGGER_DOMAIN in full_config: _level = "NONE" - logger_config = full_config[CONF_LOGGER] + logger_config = full_config[LOGGER_DOMAIN] if CONF_LOGS in logger_config: if "haier.protocol" in logger_config[CONF_LOGS]: _level = logger_config[CONF_LOGS]["haier.protocol"] @@ -444,7 +349,7 @@ def _final_validate(config) -> None: "No logger component found, logging for Haier protocol is disabled" ) cg.add_build_flag("-DHAIER_LOG_LEVEL=0") - if config.get(CONF_WIFI_SIGNAL) and CONF_WIFI not in full_config: + if config.get(CONF_WIFI_SIGNAL) and WIFI_DOMAIN not in full_config: raise cv.Invalid( f"No WiFi configured, if you want to use haier climate without WiFi add {CONF_WIFI_SIGNAL}: false to climate configuration" ) diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 87f9331d55..65a3ed2616 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -12,7 +12,7 @@ using namespace esphome::uart; namespace esphome::haier { -static const char *const TAG = "haier.climate"; +ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t COMMUNICATION_TIMEOUT_MS = 60000; constexpr size_t STATUS_REQUEST_INTERVAL_MS = 5000; constexpr size_t PROTOCOL_INITIALIZATION_INTERVAL = 10000; diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index 0ce4142fd4..5e672c3e65 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -11,7 +11,7 @@ using namespace esphome::uart; namespace esphome::haier { -static const char *const TAG = "haier.climate"; +ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr int PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET = -64; constexpr uint8_t CONTROL_MESSAGE_RETRIES = 5; diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index fdb3b779e2..01e1cbaeed 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -9,7 +9,7 @@ using namespace esphome::uart; namespace esphome::haier { -static const char *const TAG = "haier.climate"; +ESPHOME_LOG_TAG(TAG, "haier.climate"); constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr uint8_t CONTROL_MESSAGE_RETRIES = 5; constexpr std::chrono::milliseconds CONTROL_MESSAGE_RETRIES_INTERVAL = std::chrono::milliseconds(500); diff --git a/esphome/components/havells_solar/__init__.py b/esphome/components/havells_solar/__init__.py index e69de29bb2..f6fa38817c 100644 --- a/esphome/components/havells_solar/__init__.py +++ b/esphome/components/havells_solar/__init__.py @@ -0,0 +1 @@ +DOMAIN = "havells_solar" diff --git a/esphome/components/havells_solar/havells_solar.cpp b/esphome/components/havells_solar/havells_solar.cpp index d43dfbb89a..6abca8c2dd 100644 --- a/esphome/components/havells_solar/havells_solar.cpp +++ b/esphome/components/havells_solar/havells_solar.cpp @@ -6,7 +6,7 @@ namespace esphome::havells_solar { namespace helpers = modbus::helpers; -static const char *const TAG = "havells_solar"; +ESPHOME_LOG_TAG(TAG, "havells_solar"); static const uint8_t MODBUS_REGISTER_COUNT = 48; // 48 x 16-bit registers diff --git a/esphome/components/havells_solar/sensor.py b/esphome/components/havells_solar/sensor.py index efb7af2c3c..d2db05381d 100644 --- a/esphome/components/havells_solar/sensor.py +++ b/esphome/components/havells_solar/sensor.py @@ -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/__init__.py b/esphome/components/hbridge/__init__.py index 7eae863ff5..bbf8313de0 100644 --- a/esphome/components/hbridge/__init__.py +++ b/esphome/components/hbridge/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "hbridge" + hbridge_ns = cg.esphome_ns.namespace("hbridge") 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.cpp b/esphome/components/hbridge/fan/hbridge_fan.cpp index fc2a738e8d..311f82b850 100644 --- a/esphome/components/hbridge/fan/hbridge_fan.cpp +++ b/esphome/components/hbridge/fan/hbridge_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::hbridge { -static const char *const TAG = "fan.hbridge"; +ESPHOME_LOG_TAG(TAG, "fan.hbridge"); void HBridgeFan::set_hbridge_levels_(float a_level, float b_level) { this->pin_a_->set_level(a_level); 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/hbridge/switch/hbridge_switch.cpp b/esphome/components/hbridge/switch/hbridge_switch.cpp index c8e472d7aa..837c355f46 100644 --- a/esphome/components/hbridge/switch/hbridge_switch.cpp +++ b/esphome/components/hbridge/switch/hbridge_switch.cpp @@ -5,7 +5,7 @@ namespace esphome::hbridge { -static const char *const TAG = "switch.hbridge"; +ESPHOME_LOG_TAG(TAG, "switch.hbridge"); float HBridgeSwitch::get_setup_priority() const { return setup_priority::HARDWARE; } void HBridgeSwitch::setup() { diff --git a/esphome/components/hc8/__init__.py b/esphome/components/hc8/__init__.py index e1028456b0..bbcfae894a 100644 --- a/esphome/components/hc8/__init__.py +++ b/esphome/components/hc8/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@omartijn"] +DOMAIN = "hc8" diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp index 6a19f977a6..4fbfbc84d7 100644 --- a/esphome/components/hc8/hc8.cpp +++ b/esphome/components/hc8/hc8.cpp @@ -7,7 +7,7 @@ namespace esphome::hc8 { -static const char *const TAG = "hc8"; +ESPHOME_LOG_TAG(TAG, "hc8"); static const std::array HC8_COMMAND_GET_PPM{0x64, 0x69, 0x03, 0x5E, 0x4E}; static const std::array HC8_COMMAND_CALIBRATE_PREAMBLE{0x11, 0x03, 0x03}; 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/__init__.py b/esphome/components/hdc1080/__init__.py index e69de29bb2..7c1f276f3d 100644 --- a/esphome/components/hdc1080/__init__.py +++ b/esphome/components/hdc1080/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hdc1080" diff --git a/esphome/components/hdc1080/hdc1080.cpp b/esphome/components/hdc1080/hdc1080.cpp index 6692345e62..be0de1bae6 100644 --- a/esphome/components/hdc1080/hdc1080.cpp +++ b/esphome/components/hdc1080/hdc1080.cpp @@ -4,7 +4,7 @@ namespace esphome::hdc1080 { -static const char *const TAG = "hdc1080"; +ESPHOME_LOG_TAG(TAG, "hdc1080"); static const uint8_t HDC1080_CMD_CONFIGURATION = 0x02; static const uint8_t HDC1080_CMD_TEMPERATURE = 0x00; 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/__init__.py b/esphome/components/hdc2010/__init__.py index badf9dbb0c..839dc630b2 100644 --- a/esphome/components/hdc2010/__init__.py +++ b/esphome/components/hdc2010/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@optimusprimespace", "@ssieb"] +DOMAIN = "hdc2010" diff --git a/esphome/components/hdc2010/hdc2010.cpp b/esphome/components/hdc2010/hdc2010.cpp index 946cf4898f..825d7d3da9 100644 --- a/esphome/components/hdc2010/hdc2010.cpp +++ b/esphome/components/hdc2010/hdc2010.cpp @@ -5,7 +5,7 @@ namespace esphome::hdc2010 { -static const char *const TAG = "hdc2010"; +ESPHOME_LOG_TAG(TAG, "hdc2010"); // Register addresses static constexpr uint8_t REG_TEMPERATURE_LOW = 0x00; 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/__init__.py b/esphome/components/hdc2080/__init__.py index 341ea61048..e1ab22cc41 100644 --- a/esphome/components/hdc2080/__init__.py +++ b/esphome/components/hdc2080/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@G-Pereira", "@jesserockz"] +DOMAIN = "hdc2080" diff --git a/esphome/components/hdc2080/hdc2080.cpp b/esphome/components/hdc2080/hdc2080.cpp index bf3a4bc79f..8210733b4c 100644 --- a/esphome/components/hdc2080/hdc2080.cpp +++ b/esphome/components/hdc2080/hdc2080.cpp @@ -4,7 +4,7 @@ namespace esphome::hdc2080 { -static const char *const TAG = "hdc2080"; +ESPHOME_LOG_TAG(TAG, "hdc2080"); // Register map (Table 8-6) static constexpr uint8_t REG_TEMPERATURE_LOW = 0x00; // Temperature [7:0] 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/__init__.py b/esphome/components/hdc302x/__init__.py index d8165281bf..2fbfb0191c 100644 --- a/esphome/components/hdc302x/__init__.py +++ b/esphome/components/hdc302x/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@joshuasing"] +DOMAIN = "hdc302x" diff --git a/esphome/components/hdc302x/hdc302x.cpp b/esphome/components/hdc302x/hdc302x.cpp index 53d4c7f016..d542848657 100644 --- a/esphome/components/hdc302x/hdc302x.cpp +++ b/esphome/components/hdc302x/hdc302x.cpp @@ -6,7 +6,9 @@ namespace esphome::hdc302x { -static const char *const TAG = "hdc302x.sensor"; +ESPHOME_LOG_TAG(TAG, "hdc302x.sensor"); + +static constexpr uint32_t HEATER_OFF_TIMEOUT_ID = 0; // Commands (per datasheet Table 7-4) static const uint8_t HDC302X_CMD_SOFT_RESET[2] = {0x30, 0xa2}; @@ -68,14 +70,14 @@ void HDC302XComponent::start_heater(uint16_t power, uint32_t duration_ms) { return; } this->heater_active_ = true; - this->cancel_timeout("heater_off"); + this->cancel_timeout(HEATER_OFF_TIMEOUT_ID); if (duration_ms > 0) { - this->set_timeout("heater_off", duration_ms, [this]() { this->stop_heater(); }); + this->set_timeout(HEATER_OFF_TIMEOUT_ID, duration_ms, [this]() { this->stop_heater(); }); } } void HDC302XComponent::stop_heater() { - this->cancel_timeout("heater_off"); + this->cancel_timeout(HEATER_OFF_TIMEOUT_ID); if (!this->disable_heater_()) { ESP_LOGW(TAG, "Heater stop failed"); } 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/he60r/__init__.py b/esphome/components/he60r/__init__.py index c58ce8a01e..070ba595a3 100644 --- a/esphome/components/he60r/__init__.py +++ b/esphome/components/he60r/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "he60r" diff --git a/esphome/components/he60r/he60r.cpp b/esphome/components/he60r/he60r.cpp index 008505e2bb..7c67c5ffd3 100644 --- a/esphome/components/he60r/he60r.cpp +++ b/esphome/components/he60r/he60r.cpp @@ -6,7 +6,7 @@ namespace esphome::he60r { -static const char *const TAG = "he60r.cover"; +ESPHOME_LOG_TAG(TAG, "he60r.cover"); static const uint8_t QUERY_BYTE = 0x38; static const uint8_t TOGGLE_BYTE = 0x30; diff --git a/esphome/components/heatpumpir/__init__.py b/esphome/components/heatpumpir/__init__.py index e69de29bb2..f0ef79b895 100644 --- a/esphome/components/heatpumpir/__init__.py +++ b/esphome/components/heatpumpir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "heatpumpir" diff --git a/esphome/components/heatpumpir/heatpumpir.cpp b/esphome/components/heatpumpir/heatpumpir.cpp index 502f83cd5d..b4597fe4ab 100644 --- a/esphome/components/heatpumpir/heatpumpir.cpp +++ b/esphome/components/heatpumpir/heatpumpir.cpp @@ -1,5 +1,7 @@ #include "heatpumpir.h" +#include "receiver_mitsubishi_heavy.h" + #if defined(USE_ARDUINO) || defined(USE_ESP32) #include @@ -41,7 +43,7 @@ class IRSenderESPHome : public IRSender { remote_base::RemoteTransmitterBase::TransmitCall transmit_; }; -static const char *const TAG = "heatpumpir.climate"; +ESPHOME_LOG_TAG(TAG, "heatpumpir.climate"); const std::map> PROTOCOL_CONSTRUCTOR_MAP = { {PROTOCOL_AUX, []() { return new AUXHeatpumpIR(); }}, // NOLINT @@ -125,6 +127,13 @@ void HeatpumpIRClimate::setup() { } } +void HeatpumpIRClimate::control(const climate::ClimateCall &call) { + // Picking a fan speed ends HiPower or Econo, as it does on the remote + if (this->has_fan_byte_presets_() && call.get_fan_mode().has_value() && !call.get_preset().has_value()) + this->preset = climate::CLIMATE_PRESET_NONE; + climate_ir::ClimateIR::control(call); +} + void HeatpumpIRClimate::transmit_state() { uint8_t power_mode_cmd; uint8_t operating_mode_cmd; @@ -187,15 +196,19 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd = HDIR_SWING; } + // MitsubishiMSCHeatpumpIR only knows FAN_1..FAN_3 and sends anything else as auto, + // so low/medium/high have to start at FAN_1 there instead of FAN_2. Mitsubishi Heavy + // ZJ and ZMP also stop at FAN_3 and use FAN_4 and FAN_5 for HiPower and Econo. + const bool three_speed_fan = this->protocol_ == PROTOCOL_MITSUBISHI_MSC || this->has_fan_byte_presets_(); switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) { case climate::CLIMATE_FAN_LOW: - fan_speed_cmd = FAN_2; + fan_speed_cmd = three_speed_fan ? FAN_1 : FAN_2; break; case climate::CLIMATE_FAN_MEDIUM: - fan_speed_cmd = FAN_3; + fan_speed_cmd = three_speed_fan ? FAN_2 : FAN_3; break; case climate::CLIMATE_FAN_HIGH: - fan_speed_cmd = FAN_4; + fan_speed_cmd = three_speed_fan ? FAN_3 : FAN_4; break; case climate::CLIMATE_FAN_AUTO: default: @@ -203,6 +216,19 @@ void HeatpumpIRClimate::transmit_state() { break; } + if (this->has_fan_byte_presets_()) { + switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { + case climate::CLIMATE_PRESET_BOOST: + fan_speed_cmd = FAN_4; // HiPower + break; + case climate::CLIMATE_PRESET_ECO: + fan_speed_cmd = FAN_5; // Econo + break; + default: + break; + } + } + switch (this->mode) { case climate::CLIMATE_MODE_COOL: power_mode_cmd = POWER_ON; @@ -243,6 +269,23 @@ void HeatpumpIRClimate::transmit_state() { swing_h_cmd); } +bool HeatpumpIRClimate::on_receive(remote_base::RemoteReceiveData data) { + switch (this->protocol_) { + case PROTOCOL_MITSUBISHI_HEAVY_ZMP: { + uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]; + if (!decode_mitsubishi_heavy_frame(data, frame, *this)) + return false; + decode_mitsubishi_heavy_zmp(frame, *this); + break; + } + default: + return false; // no decoder for this protocol + } + this->target_temperature = clamp(this->target_temperature, this->min_temperature_, this->max_temperature_); + this->publish_state(); + return true; +} + } // namespace esphome::heatpumpir #endif diff --git a/esphome/components/heatpumpir/heatpumpir.h b/esphome/components/heatpumpir/heatpumpir.h index 8e0668d59d..d00fa91d05 100644 --- a/esphome/components/heatpumpir/heatpumpir.h +++ b/esphome/components/heatpumpir/heatpumpir.h @@ -102,7 +102,12 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_BOTH}) {} void setup() override; - void set_protocol(Protocol protocol) { this->protocol_ = protocol; } + void set_protocol(Protocol protocol) { + this->protocol_ = protocol; + if (this->has_fan_byte_presets_()) { + this->presets_ = {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO, climate::CLIMATE_PRESET_BOOST}; + } + } void set_horizontal_default(HorizontalDirection horizontal_direction) { this->default_horizontal_direction_ = horizontal_direction; } @@ -115,8 +120,15 @@ class HeatpumpIRClimate final : public climate_ir::ClimateIR { protected: HeatpumpIR *heatpump_ir_; + void control(const climate::ClimateCall &call) override; /// Transmit via IR the state of this climate controller. void transmit_state() override; + /// Mitsubishi Heavy ZJ and ZMP carry HiPower and Econo in the fan byte as FAN_4 and FAN_5 + bool has_fan_byte_presets_() const { + return this->protocol_ == PROTOCOL_MITSUBISHI_HEAVY_ZJ || this->protocol_ == PROTOCOL_MITSUBISHI_HEAVY_ZMP; + } + /// Handle received IR data from the remote control. + bool on_receive(remote_base::RemoteReceiveData data) override; Protocol protocol_; HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp new file mode 100644 index 0000000000..004eb46c00 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.cpp @@ -0,0 +1,109 @@ +#include "receiver_mitsubishi_heavy.h" +#include "heatpumpir.h" + +#if defined(USE_ARDUINO) || defined(USE_ESP32) + +#include + +namespace esphome::heatpumpir { + +static constexpr uint8_t FRAME_PREFIX[5] = {0x52, 0xAE, 0xC3, 0x26, 0xD9}; +static constexpr uint8_t XOR_CHECKSUM_EXPECTED = 0xFF; +static constexpr uint8_t PREFIX_LENGTH = 5; +static constexpr uint8_t MODE_TEMP_BYTE = 9; +static constexpr uint8_t MODE_MASK = 0x07; +static constexpr uint8_t TEMP_MASK = 0x0F; +static constexpr uint8_t TEMPERATURE_BASE = 17; +static constexpr uint8_t TEMPERATURE_SHIFT = 4; + +static bool read_raw_bytes(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + if (!data.expect_item(MITSUBISHI_HEAVY_HDR_MARK, MITSUBISHI_HEAVY_HDR_SPACE)) + return false; + + for (uint8_t i = 0; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i++) { + uint8_t byte = 0; + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ONE_SPACE)) { + byte |= 1 << bit; + } else if (!data.expect_item(MITSUBISHI_HEAVY_BIT_MARK, MITSUBISHI_HEAVY_ZERO_SPACE)) { + return false; + } + } + frame[i] = byte; + } + + return true; +} + +static bool validate_prefix(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + for (uint8_t i = 0; i < PREFIX_LENGTH; i++) { + if (frame[i] != FRAME_PREFIX[i]) + return false; + } + return true; +} + +static bool validate_checksums(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + for (uint8_t i = PREFIX_LENGTH; i < MITSUBISHI_HEAVY_FRAME_LENGTH; i += 2) { + if ((uint8_t) (frame[i] ^ frame[i + 1]) != XOR_CHECKSUM_EXPECTED) + return false; + } + return true; +} + +static bool decode_mode_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], climate::ClimateMode &mode) { + if (frame[MODE_TEMP_BYTE] & MITSUBISHI_HEAVY_MODE_OFF) { + mode = climate::CLIMATE_MODE_OFF; + return true; + } + switch (frame[MODE_TEMP_BYTE] & MODE_MASK) { + case MITSUBISHI_HEAVY_MODE_AUTO: + mode = climate::CLIMATE_MODE_HEAT_COOL; + return true; + case MITSUBISHI_HEAVY_MODE_HEAT: + mode = climate::CLIMATE_MODE_HEAT; + return true; + case MITSUBISHI_HEAVY_MODE_COOL: + mode = climate::CLIMATE_MODE_COOL; + return true; + case MITSUBISHI_HEAVY_MODE_DRY: + mode = climate::CLIMATE_MODE_DRY; + return true; + case MITSUBISHI_HEAVY_MODE_FAN: + mode = climate::CLIMATE_MODE_FAN_ONLY; + return true; + default: + return false; + } +} + +static float decode_temperature_field(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH]) { + uint8_t temp_nibble = frame[MODE_TEMP_BYTE] >> TEMPERATURE_SHIFT; + uint8_t inverted_nibble = ~temp_nibble; + uint8_t temp_offset = inverted_nibble & TEMP_MASK; + return TEMPERATURE_BASE + temp_offset; +} + +static bool apply_climate_state(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) { + climate::ClimateMode mode; + if (!decode_mode_field(frame, mode)) + return false; + climate.mode = mode; + climate.target_temperature = decode_temperature_field(frame); + return true; +} + +bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], + HeatpumpIRClimate &climate) { + if (!read_raw_bytes(data, frame)) + return false; + if (!validate_prefix(frame)) + return false; + if (!validate_checksums(frame)) + return false; + return apply_climate_state(frame, climate); +} + +} // namespace esphome::heatpumpir + +#endif diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h new file mode 100644 index 0000000000..61e3c34ca4 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/remote_base/remote_base.h" + +namespace esphome::heatpumpir { + +class HeatpumpIRClimate; + +/// Bytes in a Mitsubishi Heavy IR frame, prefix included +inline constexpr uint8_t MITSUBISHI_HEAVY_FRAME_LENGTH = 11; + +/// Read a Mitsubishi Heavy frame into `frame`, check its prefix and inverted byte pairs, and apply the +/// fields every variant shares (mode and target temperature). Returns false when no valid frame was read. +bool decode_mitsubishi_heavy_frame(remote_base::RemoteReceiveData &data, uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], + HeatpumpIRClimate &climate); + +/// Apply the ZMP-specific fields (fan, preset, swing) of a validated frame. +void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate); + +} // namespace esphome::heatpumpir diff --git a/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp new file mode 100644 index 0000000000..4d92c35c77 --- /dev/null +++ b/esphome/components/heatpumpir/receiver_mitsubishi_heavy_zmp.cpp @@ -0,0 +1,63 @@ +#include "receiver_mitsubishi_heavy.h" +#include "heatpumpir.h" + +#if defined(USE_ARDUINO) || defined(USE_ESP32) + +#include + +namespace esphome::heatpumpir { + +// Fan speed sits in bits 7:5 of byte 7; the vertical swing code spans bit 1 of byte 5 and bits 4:3 of byte 7 +static constexpr uint8_t BYTE_SWING_H = 5; +static constexpr uint8_t BYTE_FAN = 7; +static constexpr uint8_t FAN_SPEED_MASK = 0xE0; +static constexpr uint8_t VS_MASK_BYTE5 = 0x02; +static constexpr uint8_t VS_MASK_BYTE7 = 0x18; +// The library has no horizontal swing for ZMP and always sends MITSUBISHI_HEAVY_ZMP_HS_STOP (0xCC) in byte 5. +// Captures of the remote show 0x5C while it swings; the mask leaves out the vertical swing and clean bits. +static constexpr uint8_t HORIZONTAL_SWING_MASK = 0xDC; +static constexpr uint8_t HORIZONTAL_SWING_ACTIVE = 0x5C; + +void decode_mitsubishi_heavy_zmp(const uint8_t frame[MITSUBISHI_HEAVY_FRAME_LENGTH], HeatpumpIRClimate &climate) { + uint8_t fan = frame[BYTE_FAN] & FAN_SPEED_MASK; + climate.preset = climate::CLIMATE_PRESET_NONE; + switch (fan) { + case MITSUBISHI_HEAVY_ZMP_FAN_AUTO: + climate.fan_mode = climate::CLIMATE_FAN_AUTO; + break; + case MITSUBISHI_HEAVY_ZMP_FAN1: + climate.fan_mode = climate::CLIMATE_FAN_LOW; + break; + case MITSUBISHI_HEAVY_ZMP_FAN2: + climate.fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case MITSUBISHI_HEAVY_ZMP_FAN3: + climate.fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case MITSUBISHI_HEAVY_ZMP_HIPOWER: + climate.preset = climate::CLIMATE_PRESET_BOOST; + break; // the remote keeps the fan mode and restores it when the preset ends + case MITSUBISHI_HEAVY_ZMP_ECONO: + climate.preset = climate::CLIMATE_PRESET_ECO; + break; + } + + uint8_t swing_h = frame[BYTE_SWING_H] & HORIZONTAL_SWING_MASK; + uint8_t swing_v = (frame[BYTE_SWING_H] & VS_MASK_BYTE5) | (frame[BYTE_FAN] & VS_MASK_BYTE7); + bool h_swing = (swing_h == HORIZONTAL_SWING_ACTIVE); + bool v_swing = (swing_v == MITSUBISHI_HEAVY_ZMP_VS_SWING); + + if (h_swing && v_swing) { + climate.swing_mode = climate::CLIMATE_SWING_BOTH; + } else if (h_swing) { + climate.swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + } else if (v_swing) { + climate.swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + climate.swing_mode = climate::CLIMATE_SWING_OFF; + } +} + +} // namespace esphome::heatpumpir + +#endif diff --git a/esphome/components/hitachi_ac344/__init__.py b/esphome/components/hitachi_ac344/__init__.py index e69de29bb2..15dac43260 100644 --- a/esphome/components/hitachi_ac344/__init__.py +++ b/esphome/components/hitachi_ac344/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hitachi_ac344" diff --git a/esphome/components/hitachi_ac344/hitachi_ac344.cpp b/esphome/components/hitachi_ac344/hitachi_ac344.cpp index ea211a7a59..c712500a53 100644 --- a/esphome/components/hitachi_ac344/hitachi_ac344.cpp +++ b/esphome/components/hitachi_ac344/hitachi_ac344.cpp @@ -2,7 +2,7 @@ namespace esphome::hitachi_ac344 { -static const char *const TAG = "climate.hitachi_ac344"; +ESPHOME_LOG_TAG(TAG, "climate.hitachi_ac344"); void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, const uint8_t data) { if (offset >= 8 || !nbits) diff --git a/esphome/components/hitachi_ac424/__init__.py b/esphome/components/hitachi_ac424/__init__.py index 10f2c27fe8..8372b84c7e 100644 --- a/esphome/components/hitachi_ac424/__init__.py +++ b/esphome/components/hitachi_ac424/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@sourabhjaiswal"] +DOMAIN = "hitachi_ac424" diff --git a/esphome/components/hitachi_ac424/hitachi_ac424.cpp b/esphome/components/hitachi_ac424/hitachi_ac424.cpp index cdf8dda22c..d8f7f66179 100644 --- a/esphome/components/hitachi_ac424/hitachi_ac424.cpp +++ b/esphome/components/hitachi_ac424/hitachi_ac424.cpp @@ -2,7 +2,7 @@ namespace esphome::hitachi_ac424 { -static const char *const TAG = "climate.hitachi_ac424"; +ESPHOME_LOG_TAG(TAG, "climate.hitachi_ac424"); void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, const uint8_t data) { if (offset >= 8 || !nbits) diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py index b8040e5579..72d3cc2ada 100644 --- a/esphome/components/hlk_fm22x/__init__.py +++ b/esphome/components/hlk_fm22x/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CODEOWNERS = ["@OnFreund"] DEPENDENCIES = ["uart"] AUTO_LOAD = ["binary_sensor", "sensor", "text_sensor"] +DOMAIN = "hlk_fm22x" MULTI_CONF = True CONF_HLK_FM22X_ID = "hlk_fm22x_id" diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp index a924259802..e4845daa57 100644 --- a/esphome/components/hlk_fm22x/hlk_fm22x.cpp +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -5,7 +5,7 @@ namespace esphome::hlk_fm22x { -static const char *const TAG = "hlk_fm22x"; +ESPHOME_LOG_TAG(TAG, "hlk_fm22x"); static constexpr uint32_t PAYLOAD_TIMEOUT_MS = 20; void HlkFm22xComponent::setup() { diff --git a/esphome/components/hlw8012/__init__.py b/esphome/components/hlw8012/__init__.py index e69de29bb2..3da6262ce7 100644 --- a/esphome/components/hlw8012/__init__.py +++ b/esphome/components/hlw8012/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hlw8012" diff --git a/esphome/components/hlw8012/hlw8012.cpp b/esphome/components/hlw8012/hlw8012.cpp index 9ef81f075d..a14a9aaf48 100644 --- a/esphome/components/hlw8012/hlw8012.cpp +++ b/esphome/components/hlw8012/hlw8012.cpp @@ -3,7 +3,7 @@ namespace esphome::hlw8012 { -static const char *const TAG = "hlw8012"; +ESPHOME_LOG_TAG(TAG, "hlw8012"); // valid for HLW8012 and CSE7759 static const uint32_t HLW8012_CLOCK_FREQUENCY = 3579000; 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/__init__.py b/esphome/components/hlw8032/__init__.py index 4908e10037..fc9c674659 100644 --- a/esphome/components/hlw8032/__init__.py +++ b/esphome/components/hlw8032/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@rici4kubicek"] +DOMAIN = "hlw8032" diff --git a/esphome/components/hlw8032/hlw8032.cpp b/esphome/components/hlw8032/hlw8032.cpp index 55e6664a8b..cbb6b6c628 100644 --- a/esphome/components/hlw8032/hlw8032.cpp +++ b/esphome/components/hlw8032/hlw8032.cpp @@ -4,7 +4,7 @@ namespace esphome::hlw8032 { -static const char *const TAG = "hlw8032"; +ESPHOME_LOG_TAG(TAG, "hlw8032"); static constexpr uint8_t STATE_REG_OFFSET = 0; static constexpr uint8_t VOLTAGE_PARAM_OFFSET = 2; 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/__init__.py b/esphome/components/hm3301/__init__.py index e69de29bb2..eb45989270 100644 --- a/esphome/components/hm3301/__init__.py +++ b/esphome/components/hm3301/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hm3301" diff --git a/esphome/components/hm3301/hm3301.cpp b/esphome/components/hm3301/hm3301.cpp index 02c6e75146..ddfe77d3ac 100644 --- a/esphome/components/hm3301/hm3301.cpp +++ b/esphome/components/hm3301/hm3301.cpp @@ -3,7 +3,7 @@ namespace esphome::hm3301 { -static const char *const TAG = "hm3301.sensor"; +ESPHOME_LOG_TAG(TAG, "hm3301.sensor"); static const uint8_t PM_1_0_VALUE_INDEX = 5; static const uint8_t PM_2_5_VALUE_INDEX = 6; @@ -80,14 +80,7 @@ void HM3301Component::update() { this->status_clear_warning(); } -bool HM3301Component::validate_checksum_(const uint8_t *data) { - uint8_t sum = 0; - for (int i = 0; i < 28; i++) { - sum += data[i]; - } - - return sum == data[28]; -} +bool HM3301Component::validate_checksum_(const uint8_t *data) { return sum_bytes(data, 28) == data[28]; } uint16_t HM3301Component::get_sensor_value_(const uint8_t *data, uint8_t i) { return (uint16_t) data[i * 2] << 8 | data[i * 2 + 1]; diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 2fa82b2710..d5e15f7c62 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -2,7 +2,8 @@ import logging import esphome.codegen as cg from esphome.components import i2c, sensor -from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE +from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE +from esphome.components.const import CONF_AQI import esphome.config_validation as cv from esphome.const import ( CONF_ID, @@ -92,17 +93,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/hmac_md5/__init__.py b/esphome/components/hmac_md5/__init__.py index e37eb9b116..72f9aa60fe 100644 --- a/esphome/components/hmac_md5/__init__.py +++ b/esphome/components/hmac_md5/__init__.py @@ -2,5 +2,6 @@ import esphome.config_validation as cv AUTO_LOAD = ["md5"] CODEOWNERS = ["@dwmw2"] +DOMAIN = "hmac_md5" CONFIG_SCHEMA = cv.Schema({}) diff --git a/esphome/components/hmac_sha256/__init__.py b/esphome/components/hmac_sha256/__init__.py index 158d740dc5..4f42c968a3 100644 --- a/esphome/components/hmac_sha256/__init__.py +++ b/esphome/components/hmac_sha256/__init__.py @@ -2,5 +2,6 @@ import esphome.config_validation as cv AUTO_LOAD = ["sha256"] CODEOWNERS = ["@dwmw2"] +DOMAIN = "hmac_sha256" CONFIG_SCHEMA = cv.Schema({}) diff --git a/esphome/components/hmc5883l/__init__.py b/esphome/components/hmc5883l/__init__.py index e69de29bb2..5326507e44 100644 --- a/esphome/components/hmc5883l/__init__.py +++ b/esphome/components/hmc5883l/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hmc5883l" diff --git a/esphome/components/hmc5883l/hmc5883l.cpp b/esphome/components/hmc5883l/hmc5883l.cpp index c6b7da6610..c747e7968e 100644 --- a/esphome/components/hmc5883l/hmc5883l.cpp +++ b/esphome/components/hmc5883l/hmc5883l.cpp @@ -6,7 +6,7 @@ namespace esphome::hmc5883l { -static const char *const TAG = "hmc5883l"; +ESPHOME_LOG_TAG(TAG, "hmc5883l"); static const uint8_t HMC5883L_ADDRESS = 0x1E; static const uint8_t HMC5883L_REGISTER_CONFIG_A = 0x00; static const uint8_t HMC5883L_REGISTER_CONFIG_B = 0x01; 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/__init__.py b/esphome/components/hoermann_hcp/__init__.py index 958b495c2e..addd1b299b 100644 --- a/esphome/components/hoermann_hcp/__init__.py +++ b/esphome/components/hoermann_hcp/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@zweckj"] DEPENDENCIES = ["modbus"] +DOMAIN = "hoermann_hcp" MULTI_CONF = True CONF_HOERMANN_HCP_ID = "hoermann_hcp_id" diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp index edce6ce4c2..db32eb2952 100644 --- a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::hoermann_hcp { -static const char *const TAG = "hoermann_hcp.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp.binary_sensor"); void HoermannHcpConnectedBinarySensor::setup() { // Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp index 66a141758e..ab2afc74d9 100644 --- a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -4,7 +4,7 @@ namespace esphome::hoermann_hcp { -static const char *const TAG = "hoermann_hcp.cover"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp.cover"); cover::CoverTraits HoermannHcpCover::get_traits() { cover::CoverTraits traits; diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index 4aa2c79bb1..bbf1380d17 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -1,11 +1,15 @@ #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 { -static const char *const TAG = "hoermann_hcp"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp"); // Hoermann HCP holding-register blocks. static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller @@ -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..6195c5d63d 100644 --- a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -4,7 +4,7 @@ namespace esphome::hoermann_hcp { -static const char *const TAG = "hoermann_hcp.light"; +ESPHOME_LOG_TAG(TAG, "hoermann_hcp.light"); light::LightTraits HoermannHcpLight::get_traits() { auto traits = light::LightTraits(); @@ -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..cc8d15d723 --- /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 { + +ESPHOME_LOG_TAG(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..3851bc3c0b 100644 --- a/esphome/components/homeassistant/__init__.py +++ b/esphome/components/homeassistant/__init__.py @@ -2,11 +2,13 @@ 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"] +DOMAIN = "homeassistant" homeassistant_ns = cg.esphome_ns.namespace("homeassistant") @@ -14,10 +16,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 +30,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 +38,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/binary_sensor/homeassistant_binary_sensor.cpp b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp index 735fb9f5da..d14158399c 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "homeassistant.binary_sensor"); void HomeassistantBinarySensor::setup() { api::global_api_server->subscribe_home_assistant_state(this->entity_id_, this->attribute_, [this](StringRef state) { 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..6cf1c87eaa --- /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 { + +ESPHOME_LOG_TAG(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..df92aad38e 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" @@ -7,7 +9,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.number"; +ESPHOME_LOG_TAG(TAG, "homeassistant.number"); void HomeassistantNumber::state_changed_(StringRef state) { auto number_value = parse_number(state.c_str()); @@ -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..189a6b1f37 --- /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 { + +ESPHOME_LOG_TAG(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/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index b79a56953a..8256dad484 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.sensor"; +ESPHOME_LOG_TAG(TAG, "homeassistant.sensor"); void HomeassistantSensor::setup() { api::global_api_server->subscribe_home_assistant_state(this->entity_id_, this->attribute_, [this](StringRef state) { 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..841f16df20 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.switch"; +ESPHOME_LOG_TAG(TAG, "homeassistant.switch"); using namespace esphome::switch_; @@ -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..7155f6ee18 --- /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 { + +ESPHOME_LOG_TAG(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/homeassistant/text_sensor/homeassistant_text_sensor.cpp b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp index 40d7455ece..853b0d9b38 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.text_sensor"; +ESPHOME_LOG_TAG(TAG, "homeassistant.text_sensor"); void HomeassistantTextSensor::setup() { api::global_api_server->subscribe_home_assistant_state(this->entity_id_, this->attribute_, [this](StringRef state) { diff --git a/esphome/components/homeassistant/time/homeassistant_time.cpp b/esphome/components/homeassistant/time/homeassistant_time.cpp index c08bd73d62..a668d66800 100644 --- a/esphome/components/homeassistant/time/homeassistant_time.cpp +++ b/esphome/components/homeassistant/time/homeassistant_time.cpp @@ -3,7 +3,7 @@ namespace esphome::homeassistant { -static const char *const TAG = "homeassistant.time"; +ESPHOME_LOG_TAG(TAG, "homeassistant.time"); void HomeassistantTime::dump_config() { ESP_LOGCONFIG(TAG, "Home Assistant Time"); diff --git a/esphome/components/honeywell_hih_i2c/__init__.py b/esphome/components/honeywell_hih_i2c/__init__.py index 8d13fcb152..5808cb6fc8 100644 --- a/esphome/components/honeywell_hih_i2c/__init__.py +++ b/esphome/components/honeywell_hih_i2c/__init__.py @@ -1,3 +1,4 @@ """Support for Honeywell HumidIcon HIH""" CODEOWNERS = ["@Benichou34"] +DOMAIN = "honeywell_hih_i2c" diff --git a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp index 7f075847fd..352d3f7f01 100644 --- a/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp +++ b/esphome/components/honeywell_hih_i2c/honeywell_hih.cpp @@ -7,7 +7,9 @@ namespace esphome::honeywell_hih_i2c { -static const char *const TAG = "honeywell_hih.i2c"; +ESPHOME_LOG_TAG(TAG, "honeywell_hih.i2c"); + +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; static const uint8_t REQUEST_CMD[1] = {0x00}; // Measurement Request Format static const uint16_t MAX_COUNT = 0x3FFE; // 2^14 - 2 @@ -68,13 +70,13 @@ void HoneywellHIComponent::update() { this->start_measurement_(); // The measurement cycle duration is typically 36.65 ms for temperature and humidity readings. - this->set_timeout("meas_timeout", 100, [this] { this->measurement_timeout_(); }); + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 100, [this] { this->measurement_timeout_(); }); } void HoneywellHIComponent::loop() { if (this->measurement_running_ && this->is_measurement_ready_()) { this->measurement_running_ = false; - this->cancel_timeout("meas_timeout"); + this->cancel_timeout(MEASUREMENT_TIMEOUT_ID); this->read_sensor_data_(); } } 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/__init__.py b/esphome/components/honeywellabp/__init__.py index 03440d675d..e6e1fd8915 100644 --- a/esphome/components/honeywellabp/__init__.py +++ b/esphome/components/honeywellabp/__init__.py @@ -1 +1,3 @@ """Support for Honeywell ABP""" + +DOMAIN = "honeywellabp" diff --git a/esphome/components/honeywellabp/honeywellabp.cpp b/esphome/components/honeywellabp/honeywellabp.cpp index dd86b95c78..27f3635a73 100644 --- a/esphome/components/honeywellabp/honeywellabp.cpp +++ b/esphome/components/honeywellabp/honeywellabp.cpp @@ -3,7 +3,7 @@ namespace esphome::honeywellabp { -static const char *const TAG = "honeywellabp"; +ESPHOME_LOG_TAG(TAG, "honeywellabp"); const float MIN_COUNT = 1638.4; // 1638 counts (10% of 2^14 counts or 0x0666) const float MAX_COUNT = 14745.6; // 14745 counts (90% of 2^14 counts or 0x3999) 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/__init__.py b/esphome/components/honeywellabp2_i2c/__init__.py index 29a910eca9..1205355bc4 100644 --- a/esphome/components/honeywellabp2_i2c/__init__.py +++ b/esphome/components/honeywellabp2_i2c/__init__.py @@ -1,3 +1,4 @@ """Support for Honeywell ABP2""" CODEOWNERS = ["@jpfaff"] +DOMAIN = "honeywellabp2_i2c" diff --git a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp index bfab045838..2ca3fb821c 100644 --- a/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp +++ b/esphome/components/honeywellabp2_i2c/honeywellabp2.cpp @@ -9,7 +9,9 @@ static const uint8_t STATUS_BIT_BUSY = 5; static const uint8_t STATUS_BIT_ERROR = 2; static const uint8_t STATUS_MATH_SAT = 0; -static const char *const TAG = "honeywellabp2"; +ESPHOME_LOG_TAG(TAG, "honeywellabp2"); + +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; void HONEYWELLABP2Sensor::read_sensor_data() { if (this->read(raw_data_, 7) != i2c::ERROR_OK) { @@ -62,7 +64,7 @@ float HONEYWELLABP2Sensor::get_temperature() { return this->last_temperature_; } void HONEYWELLABP2Sensor::loop() { if (this->measurement_running_) { if (this->is_measurement_ready()) { - this->cancel_timeout("meas_timeout"); + this->cancel_timeout(MEASUREMENT_TIMEOUT_ID); this->read_sensor_data(); if (pressure_sensor_ != nullptr) { @@ -79,7 +81,7 @@ void HONEYWELLABP2Sensor::update() { ESP_LOGV(TAG, "Update Honeywell ABP2 Sensor"); this->start_measurement(); - this->set_timeout("meas_timeout", 100, [this] { this->measurement_timeout(); }); + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 100, [this] { this->measurement_timeout(); }); } void HONEYWELLABP2Sensor::dump_config() { 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..d0ec6f2535 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 @@ -20,6 +22,7 @@ from .gpio import host_pin_to_code # noqa: F401 CODEOWNERS = ["@esphome/core", "@clydebarrow"] AUTO_LOAD = ["network", "preferences"] +DOMAIN = "host" IS_TARGET_PLATFORM = True @@ -37,7 +40,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 +53,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/host/gpio.cpp b/esphome/components/host/gpio.cpp index f99b82bcc2..b57bd41188 100644 --- a/esphome/components/host/gpio.cpp +++ b/esphome/components/host/gpio.cpp @@ -1,12 +1,13 @@ #ifdef USE_HOST #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome { namespace host { -static const char *const TAG = "host"; +ESPHOME_LOG_TAG(TAG, "host"); struct ISRPinArg { uint8_t pin; @@ -20,7 +21,7 @@ ISRInternalGPIOPin HostGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void HostGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void HostGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { ESP_LOGD(TAG, "Attaching interrupt %p to pin %d and mode %d", func, pin_, (uint32_t) type); } void HostGPIOPin::pin_mode(gpio::Flags flags) { ESP_LOGD(TAG, "Setting pin %d mode to %02X", pin_, (uint32_t) flags); } diff --git a/esphome/components/host/gpio.h b/esphome/components/host/gpio.h index bd2d09257b..fd3ebefe1f 100644 --- a/esphome/components/host/gpio.h +++ b/esphome/components/host/gpio.h @@ -2,11 +2,11 @@ #ifdef USE_HOST -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::host { -class HostGPIOPin final : public InternalGPIOPin { +class HostGPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -17,14 +17,18 @@ class HostGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/host/hal.h b/esphome/components/host/hal.h index 12abf6684d..c767632772 100644 --- a/esphome/components/host/hal.h +++ b/esphome/components/host/hal.h @@ -3,6 +3,7 @@ #ifdef USE_HOST #include +#include #include #define IRAM_ATTR @@ -16,6 +17,16 @@ namespace esphome { /// Host has no ISR concept. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +inline pthread_t &main_loop_thread() { + static pthread_t thread{}; + return thread; +} + +/// arch_init() stores the thread before any component runs, so no unset case exists. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return pthread_equal(pthread_self(), main_loop_thread()) != 0; +} + __attribute__((always_inline)) inline void yield() { ::sched_yield(); } void delay(uint32_t ms); @@ -25,7 +36,7 @@ uint64_t millis_64(); void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming) uint32_t arch_get_cpu_cycle_count(); -__attribute__((always_inline)) inline void arch_init() {} +__attribute__((always_inline)) inline void arch_init() { main_loop_thread() = pthread_self(); } __attribute__((always_inline)) inline void arch_feed_wdt() {} __attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 7274d9de57..9e203efcb9 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -14,7 +14,7 @@ namespace esphome { -static const char *const TAG = "helpers.host"; +ESPHOME_LOG_TAG(TAG, "helpers.host"); bool random_bytes(uint8_t *data, size_t len) { FILE *fp = fopen("/dev/urandom", "r"); diff --git a/esphome/components/host/preferences.cpp b/esphome/components/host/preferences.cpp index 497b9d11e5..9a8208e96e 100644 --- a/esphome/components/host/preferences.cpp +++ b/esphome/components/host/preferences.cpp @@ -9,7 +9,7 @@ namespace esphome::host { namespace fs = std::filesystem; -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); void HostPreferences::setup_() { if (this->setup_complete_) diff --git a/esphome/components/hrxl_maxsonar_wr/__init__.py b/esphome/components/hrxl_maxsonar_wr/__init__.py index e69de29bb2..2210989d9f 100644 --- a/esphome/components/hrxl_maxsonar_wr/__init__.py +++ b/esphome/components/hrxl_maxsonar_wr/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hrxl_maxsonar_wr" diff --git a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp index b323dd0436..1d58a4317a 100644 --- a/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp +++ b/esphome/components/hrxl_maxsonar_wr/hrxl_maxsonar_wr.cpp @@ -11,7 +11,7 @@ namespace esphome::hrxl_maxsonar_wr { -static const char *const TAG = "hrxl.maxsonar.wr.sensor"; +ESPHOME_LOG_TAG(TAG, "hrxl.maxsonar.wr.sensor"); static const uint8_t ASCII_CR = 0x0D; static const uint8_t ASCII_NBSP = 0xFF; static const int MAX_DATA_LENGTH_BYTES = 6; diff --git a/esphome/components/hte501/__init__.py b/esphome/components/hte501/__init__.py index e69de29bb2..18bffe2b22 100644 --- a/esphome/components/hte501/__init__.py +++ b/esphome/components/hte501/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hte501" diff --git a/esphome/components/hte501/hte501.cpp b/esphome/components/hte501/hte501.cpp index 67bfb1917e..e955531b57 100644 --- a/esphome/components/hte501/hte501.cpp +++ b/esphome/components/hte501/hte501.cpp @@ -4,7 +4,7 @@ namespace esphome::hte501 { -static const char *const TAG = "hte501"; +ESPHOME_LOG_TAG(TAG, "hte501"); static constexpr size_t HTE501_SERIAL_NUMBER_SIZE = 7; 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..89fe5f8b1c 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -4,7 +4,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32 -from esphome.components.const import CONF_REQUEST_HEADERS +from esphome.components.const import CONF_REQUEST_HEADERS, CONF_VERIFY_SSL from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -30,6 +30,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["network"] AUTO_LOAD = ["json", "watchdog"] +DOMAIN = "http_request" http_request_ns = cg.esphome_ns.namespace("http_request") HttpRequestComponent = http_request_ns.class_("HttpRequestComponent", cg.Component) @@ -38,6 +39,7 @@ HttpRequestIDF = http_request_ns.class_("HttpRequestIDF", HttpRequestComponent) HttpRequestHost = http_request_ns.class_("HttpRequestHost", HttpRequestComponent) HttpContainer = http_request_ns.class_("HttpContainer") +RequestHeader = http_request_ns.struct("RequestHeader") HttpRequestSendAction = http_request_ns.class_( "HttpRequestSendAction", automation.Action @@ -52,7 +54,6 @@ HttpRequestResponseTrigger = http_request_ns.class_( CONF_HTTP_REQUEST_ID = "http_request_id" CONF_USERAGENT = "useragent" -CONF_VERIFY_SSL = "verify_ssl" CONF_FOLLOW_REDIRECTS = "follow_redirects" CONF_REDIRECT_LIMIT = "redirect_limit" CONF_BUFFER_SIZE_RX = "buffer_size_rx" @@ -202,11 +203,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])) @@ -230,14 +227,8 @@ async def to_code(config: ConfigType) -> None: # use_full_certificate_bundle: true esp32.require_certificate_bundle() - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_INSECURE", - not config.get(CONF_VERIFY_SSL), - ) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", - not config.get(CONF_VERIFY_SSL), - ) + else: + esp32.allow_insecure_tls() if CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) if CORE.is_rp2 and CORE.using_arduino: @@ -370,12 +361,27 @@ async def http_request_action_to_code( for key in json_: template_ = await cg.templatable(json_[key], args, cg.std_string) cg.add(var.add_json(key, template_)) - request_headers = config.get(CONF_REQUEST_HEADERS, {}) - if request_headers: - cg.add(var.init_request_headers(len(request_headers))) - for key, value in request_headers.items(): - template_ = await cg.templatable(value, args, cg.const_char_ptr) - cg.add(var.add_request_header(key, template_)) + if request_headers := config.get(CONF_REQUEST_HEADERS): + # A user lambda may keep static state, so only all-constant lists are shared + share = not any(map(cg.is_template, request_headers.values())) + # Constant headers ignore their arguments, so drop the names: identical lists + # from scripts with different parameter names then render the same table. + header_args = [(arg_type, "") for arg_type, _ in args] if share else args + headers = [ + cg.StructInitializer( + "", + ("name", key), + ("value", await cg.templatable(value, header_args, cg.const_char_ptr)), + ) + for key, value in request_headers.items() + ] + table = cg.shared_progmem_array( + "http_request_headers", + RequestHeader.template(*[arg_type for arg_type, _ in args]), + headers, + share=share, + ) + cg.add(var.set_request_headers(table, len(headers))) for value in config.get(CONF_COLLECT_HEADERS, []): cg.add(var.add_collect_header(value.lower())) diff --git a/esphome/components/http_request/http_request.cpp b/esphome/components/http_request/http_request.cpp index d45208ed5d..e689c82d2f 100644 --- a/esphome/components/http_request/http_request.cpp +++ b/esphome/components/http_request/http_request.cpp @@ -6,7 +6,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request"; +ESPHOME_LOG_TAG(TAG, "http_request"); void HttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, @@ -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 71668b8556..424fcf84e8 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -22,6 +22,12 @@ struct Header { std::string value; }; +/// One request header of an action; codegen puts these in a shared flash table. +template struct RequestHeader { + const char *name; + const char *(*value)(Ts...); +}; + // Some common HTTP status codes enum HttpStatus { HTTP_STATUS_OK = 200, @@ -268,6 +274,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: @@ -487,9 +498,12 @@ template class HttpRequestSendAction final : public Actionrequest_headers_.init(count); } - void add_request_header(const char *key, TemplatableFn value) { - this->request_headers_.push_back({key, value}); + static_assert(std::is_trivially_copyable_v> && + sizeof(RequestHeader) % sizeof(uint32_t) == 0, + "ESP8266 reads request headers from flash in whole words"); + /// Codegen only: the table must outlive the action. + void set_request_headers(const RequestHeader *headers, size_t count) { + this->request_headers_ = ConstVector>(headers, count); } void add_collect_header(const char *value) { this->lower_case_collect_headers_.emplace_back(value); } @@ -525,8 +539,8 @@ template class HttpRequestSendAction final : public Action request_headers; request_headers.reserve(this->request_headers_.size()); - for (const auto &[key, val] : this->request_headers_) { - request_headers.push_back({key, val.value(x...)}); + for (const auto &header : this->request_headers_) { + request_headers.push_back({header.name, header.value(x...)}); } auto container = this->parent_->start(this->url_.value(x...).c_str(), this->method_.value(x...), body, @@ -591,7 +605,7 @@ template class HttpRequestSendAction final : public Action>> request_headers_{}; + ConstVector> request_headers_{}; std::vector lower_case_collect_headers_{"content-type", "content-length"}; FixedVector>> json_{}; std::function json_func_{nullptr}; diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index 0d968222e9..deb9c0fec2 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -18,7 +18,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request"; +ESPHOME_LOG_TAG(TAG, "http_request"); #ifdef USE_ESP8266 // ESP8266 Arduino core (WiFiClientSecureBearSSL.cpp) returns -1000 on OOM static constexpr int ESP8266_SSL_ERR_OOM = -1000; @@ -28,7 +28,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -85,7 +85,7 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons if (!status) { ESP_LOGW(TAG, "HTTP Request failed; URL: %s", url); container->end(); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); return nullptr; } @@ -143,13 +143,13 @@ std::shared_ptr HttpRequestArduino::perform(const char *url, cons ESP_LOGW(TAG, "HTTP Request failed; URL: %s; Error: %s", url, HTTPClient::errorToString(container->status_code).c_str()); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); container->end(); return nullptr; } if (!is_success(container->status_code)) { ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(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.cpp b/esphome/components/http_request/http_request_host.cpp index a788970202..0130445acd 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -16,13 +16,13 @@ namespace esphome::http_request { -static const char *const TAG = "http_request"; +ESPHOME_LOG_TAG(TAG, "http_request"); 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()) { - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); ESP_LOGW(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -101,7 +101,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c if (!result) { ESP_LOGW(TAG, "HTTP Request failed; URL: %s, error code: %u", url, (unsigned) result.error()); container->end(); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(1000); return nullptr; } App.feed_wdt(); @@ -109,7 +109,7 @@ std::shared_ptr HttpRequestHost::perform(const char *url, const c container->status_code = response.status; if (!is_success(response.status)) { ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, response.status); - this->status_momentary_error("failed", 1000); + this->status_momentary_error(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_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 4e5a2c42b5..e429df9b97 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -18,7 +18,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request"; +ESPHOME_LOG_TAG(TAG, "http_request"); static constexpr uint32_t ERROR_DURATION_MS = 1000; void HttpRequestIDF::dump_config() { @@ -54,7 +54,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch const std::vector
&request_headers, const std::vector &lower_case_collect_headers) { if (!network::is_connected()) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Not connected to network"); return nullptr; } @@ -71,7 +71,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } else if (strcmp(method, "PATCH") == 0) { method_idf = HTTP_METHOD_PATCH; } else { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; Unsupported method"); return nullptr; } @@ -110,7 +110,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch esp_http_client_handle_t client = esp_http_client_init(&config); if (client == nullptr) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed; client could not be initialized"); return nullptr; } @@ -131,7 +131,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch esp_err_t err = esp_http_client_open(client, body_len); if (err != ESP_OK) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -154,7 +154,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } if (err != ESP_OK) { - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); ESP_LOGE(TAG, "HTTP Request failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); return nullptr; @@ -179,7 +179,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch err = esp_http_client_set_redirection(client); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_set_redirection failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -192,7 +192,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch err = esp_http_client_open(client, 0); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_http_client_open failed: %s", esp_err_to_name(err)); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); esp_http_client_cleanup(client); return nullptr; } @@ -220,7 +220,7 @@ std::shared_ptr HttpRequestIDF::perform(const char *url, const ch } ESP_LOGE(TAG, "HTTP Request failed; URL: %s; Code: %d", url, container->status_code); - this->status_momentary_error("failed", ERROR_DURATION_MS); + this->status_momentary_error(ERROR_DURATION_MS); return container; } diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 7e7594c3c3..a26c366fa6 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -11,7 +11,7 @@ namespace esphome::http_request { -static const char *const TAG = "http_request.ota"; +ESPHOME_LOG_TAG(TAG, "http_request.ota"); void OtaHttpRequestComponent::dump_config() { ESP_LOGCONFIG(TAG, "Over-The-Air updates via HTTP request"); }; diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 6a74c00e8e..bedb0e98e7 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -22,7 +22,7 @@ namespace esphome::http_request { #define UPDATE_RETURN return #endif -static const char *const TAG = "http_request.update"; +ESPHOME_LOG_TAG(TAG, "http_request.update"); // Wraps UpdateInfo + error for the task→main-loop handoff. struct TaskResult { diff --git a/esphome/components/htu21d/__init__.py b/esphome/components/htu21d/__init__.py index e69de29bb2..10e6e9d66b 100644 --- a/esphome/components/htu21d/__init__.py +++ b/esphome/components/htu21d/__init__.py @@ -0,0 +1 @@ +DOMAIN = "htu21d" diff --git a/esphome/components/htu21d/htu21d.cpp b/esphome/components/htu21d/htu21d.cpp index 8a3b2cb5d5..468a7f8e6a 100644 --- a/esphome/components/htu21d/htu21d.cpp +++ b/esphome/components/htu21d/htu21d.cpp @@ -4,7 +4,7 @@ namespace esphome::htu21d { -static const char *const TAG = "htu21d"; +ESPHOME_LOG_TAG(TAG, "htu21d"); static const uint8_t HTU21D_ADDRESS = 0x40; static const uint8_t HTU21D_REGISTER_RESET = 0xFE; 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/__init__.py b/esphome/components/htu31d/__init__.py index 039693cb30..bc17a0d886 100644 --- a/esphome/components/htu31d/__init__.py +++ b/esphome/components/htu31d/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@betterengineering"] +DOMAIN = "htu31d" diff --git a/esphome/components/htu31d/htu31d.cpp b/esphome/components/htu31d/htu31d.cpp index 6821b8b69e..86c9876b2b 100644 --- a/esphome/components/htu31d/htu31d.cpp +++ b/esphome/components/htu31d/htu31d.cpp @@ -17,7 +17,7 @@ namespace esphome::htu31d { /** Logging prefix */ -static const char *const TAG = "htu31d"; +ESPHOME_LOG_TAG(TAG, "htu31d"); /** Default I2C address for the HTU31D. */ static const uint8_t HTU31D_DEFAULT_I2CADDR = 0x40; 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/__init__.py b/esphome/components/hub75/__init__.py index cd5441f749..3ee6a86415 100644 --- a/esphome/components/hub75/__init__.py +++ b/esphome/components/hub75/__init__.py @@ -1,6 +1,7 @@ from esphome.cpp_generator import MockObj CODEOWNERS = ["@stuartparmenter"] +DOMAIN = "hub75" # Use fully-qualified namespace to avoid collision with external hub75 library's global ::hub75 namespace hub75_ns = MockObj("::esphome::hub75", "::") diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py index 3522acf049..823928176a 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: @@ -603,10 +601,8 @@ async def to_code(config: ConfigType) -> None: pins_struct = _build_pins_struct(pin_expressions, e_pin_num) hub75_config = _build_config_struct(config, pins_struct, min_refresh) - # Rotation is handled by the hub75 driver (config_.rotation already set above). - # Force rotation to 0 for ESPHome's Display base class to avoid double-rotation. - if CONF_ROTATION in config: - config[CONF_ROTATION] = 0 + # The driver rotates the panel itself (config_.rotation above), so the base class must not rotate too + config.pop(CONF_ROTATION, None) # Create display and register var = cg.new_Pvariable(config[CONF_ID], hub75_config) @@ -619,9 +615,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 +624,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 d36928a83a..226b59a291 100644 --- a/esphome/components/hub75/hub75.cpp +++ b/esphome/components/hub75/hub75.cpp @@ -6,7 +6,7 @@ namespace esphome::hub75 { -static const char *const TAG = "hub75"; +ESPHOME_LOG_TAG(TAG, "hub75"); // ======================================== // Constructor @@ -202,6 +202,46 @@ void HUB75Display::set_brightness(uint8_t brightness) { } } +static Hub75Rotation to_hub75_rotation(display::DisplayRotation rotation) { + switch (rotation) { + case display::DisplayRotation::DISPLAY_ROTATION_90_DEGREES: + return Hub75Rotation::ROTATE_90; + case display::DisplayRotation::DISPLAY_ROTATION_180_DEGREES: + return Hub75Rotation::ROTATE_180; + case display::DisplayRotation::DISPLAY_ROTATION_270_DEGREES: + return Hub75Rotation::ROTATE_270; + default: + return Hub75Rotation::ROTATE_0; + } +} + +static display::DisplayRotation to_display_rotation(Hub75Rotation rotation) { + switch (rotation) { + case Hub75Rotation::ROTATE_90: + return display::DisplayRotation::DISPLAY_ROTATION_90_DEGREES; + case Hub75Rotation::ROTATE_180: + return display::DisplayRotation::DISPLAY_ROTATION_180_DEGREES; + case Hub75Rotation::ROTATE_270: + return display::DisplayRotation::DISPLAY_ROTATION_270_DEGREES; + default: + return display::DisplayRotation::DISPLAY_ROTATION_0_DEGREES; + } +} + +// Before setup() creates the driver the configuration holds the rotation, afterwards the driver does +void HUB75Display::set_rotation(display::DisplayRotation rotation) { + const Hub75Rotation hub75_rotation = to_hub75_rotation(rotation); + if (this->driver_ == nullptr) { + this->config_.rotation = hub75_rotation; + return; + } + this->driver_->set_rotation(hub75_rotation); +} + +display::DisplayRotation HUB75Display::get_rotation() const { + return to_display_rotation(this->driver_ == nullptr ? this->config_.rotation : this->driver_->get_rotation()); +} + } // namespace esphome::hub75 #endif diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h index 98bc2e52e6..122e8960fb 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" @@ -37,6 +36,9 @@ class HUB75Display final : public display::Display { // Brightness control (runtime mutable) void set_brightness(uint8_t brightness); + void set_rotation(display::DisplayRotation rotation) override; + display::DisplayRotation get_rotation() const override; + protected: // Display internal methods int get_width_internal() override { return this->driver_ != nullptr ? this->driver_->get_width() : 0; } @@ -44,20 +46,13 @@ class HUB75Display final : public display::Display { // Member variables Hub75Driver *driver_{nullptr}; - Hub75Config config_; // Immutable configuration + Hub75Config config_; // Fixed after setup(); only the rotation can change before the driver exists // Runtime state (mutable) uint8_t brightness_{128}; 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/husb238/__init__.py b/esphome/components/husb238/__init__.py new file mode 100644 index 0000000000..92bf8c998f --- /dev/null +++ b/esphome/components/husb238/__init__.py @@ -0,0 +1,33 @@ +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 = ["@latonita"] +DEPENDENCIES = ["i2c"] +DOMAIN = "husb238" +MULTI_CONF = True + +CONF_HUSB238_ID = "husb238_id" + +husb238_ns = cg.esphome_ns.namespace("husb238") +Husb238Component = husb238_ns.class_( + "Husb238Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Husb238Component), + } + ) + .extend(cv.polling_component_schema("1s")) + .extend(i2c.i2c_device_schema(0x08)) +) + + +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/husb238/binary_sensor.py b/esphome/components/husb238/binary_sensor.py new file mode 100644 index 0000000000..78d5d78a9d --- /dev/null +++ b/esphome/components/husb238/binary_sensor.py @@ -0,0 +1,38 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, DEVICE_CLASS_PLUG, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from . import CONF_HUSB238_ID, Husb238Component + +DEPENDENCIES = ["husb238"] + +CONF_ATTACHED = "attached" +CONF_CC_DIRECTION = "cc_direction" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HUSB238_ID): cv.use_id(Husb238Component), + cv.Optional(CONF_ATTACHED): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_PLUG, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + key=CONF_NAME, + ), + cv.Optional(CONF_CC_DIRECTION): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_HUSB238_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_ATTACHED, hub.set_attached_binary_sensor) + await sensors(CONF_CC_DIRECTION, hub.set_cc_direction_binary_sensor) diff --git a/esphome/components/husb238/husb238.cpp b/esphome/components/husb238/husb238.cpp new file mode 100644 index 0000000000..8ca841a7e4 --- /dev/null +++ b/esphome/components/husb238/husb238.cpp @@ -0,0 +1,51 @@ +#include "husb238.h" +#include "esphome/core/log.h" + +namespace esphome::husb238 { + +ESPHOME_LOG_TAG(TAG, "husb238"); + +void Husb238Component::setup() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void Husb238Component::update() { + uint8_t status; + if (!this->read_byte(REG_PD_STATUS1, &status)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + +#ifdef USE_BINARY_SENSOR + if (this->attached_binary_sensor_ != nullptr) { + this->attached_binary_sensor_->publish_state((status & PD_STATUS1_ATTACHED) != 0); + } + if (this->cc_direction_binary_sensor_ != nullptr) { + // The CC line only means something while a source is attached + if (status & PD_STATUS1_ATTACHED) { + this->cc_direction_binary_sensor_->publish_state((status & PD_STATUS1_CC_DIR) != 0); + } else { + this->cc_direction_binary_sensor_->invalidate_state(); + } + } +#endif +} + +void Husb238Component::dump_config() { + ESP_LOGCONFIG(TAG, "HUSB238:"); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "PD Attached", this->attached_binary_sensor_); + LOG_BINARY_SENSOR(" ", "CC Direction", this->cc_direction_binary_sensor_); +#endif +} + +} // namespace esphome::husb238 diff --git a/esphome/components/husb238/husb238.h b/esphome/components/husb238/husb238.h new file mode 100644 index 0000000000..5a3513db02 --- /dev/null +++ b/esphome/components/husb238/husb238.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::husb238 { + +static constexpr uint8_t REG_PD_STATUS1 = 0x01; +static constexpr uint8_t PD_STATUS1_ATTACHED = 1 << 6; +static constexpr uint8_t PD_STATUS1_CC_DIR = 1 << 7; + +class Husb238Component : public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(attached) + SUB_BINARY_SENSOR(cc_direction) +#endif +}; + +} // namespace esphome::husb238 diff --git a/esphome/components/hx711/__init__.py b/esphome/components/hx711/__init__.py index e69de29bb2..f9402be78e 100644 --- a/esphome/components/hx711/__init__.py +++ b/esphome/components/hx711/__init__.py @@ -0,0 +1 @@ +DOMAIN = "hx711" diff --git a/esphome/components/hx711/hx711.cpp b/esphome/components/hx711/hx711.cpp index fe2fb4c350..48afc0192d 100644 --- a/esphome/components/hx711/hx711.cpp +++ b/esphome/components/hx711/hx711.cpp @@ -4,7 +4,7 @@ namespace esphome::hx711 { -static const char *const TAG = "hx711"; +ESPHOME_LOG_TAG(TAG, "hx711"); void HX711Sensor::setup() { this->sck_pin_->setup(); diff --git a/esphome/components/hydreon_rgxx/__init__.py b/esphome/components/hydreon_rgxx/__init__.py index b488bfc1b4..313ce9158c 100644 --- a/esphome/components/hydreon_rgxx/__init__.py +++ b/esphome/components/hydreon_rgxx/__init__.py @@ -3,6 +3,7 @@ from esphome.components import uart CODEOWNERS = ["@functionpointer"] DEPENDENCIES = ["uart"] +DOMAIN = "hydreon_rgxx" hydreon_rgxx_ns = cg.esphome_ns.namespace("hydreon_rgxx") RGModel = hydreon_rgxx_ns.enum("RGModel") diff --git a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp index 05557111fc..36a7b1b351 100644 --- a/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp +++ b/esphome/components/hydreon_rgxx/hydreon_rgxx.cpp @@ -3,7 +3,10 @@ namespace esphome::hydreon_rgxx { -static const char *const TAG = "hydreon_rgxx.sensor"; +ESPHOME_LOG_TAG(TAG, "hydreon_rgxx.sensor"); + +static constexpr uint32_t REBOOT_INTERVAL_ID = 0; + static const int MAX_DATA_LENGTH_BYTES = 80; static const uint8_t ASCII_LF = 0x0A; #define HYDREON_RGXX_COMMA , @@ -138,7 +141,7 @@ void HydreonRGxxComponent::loop() { */ void HydreonRGxxComponent::schedule_reboot_() { this->boot_count_ = 0; - this->set_interval("reboot", 5000, [this]() { + this->set_interval(REBOOT_INTERVAL_ID, 5000, [this]() { if (this->boot_count_ < 0) { ESP_LOGW(TAG, "hydreon_rgxx failed to boot %d times", -this->boot_count_); } @@ -188,7 +191,7 @@ void HydreonRGxxComponent::process_line_() { } else { this->boot_count_++; } - this->cancel_interval("reboot"); + this->cancel_interval(REBOOT_INTERVAL_ID); this->no_response_count_ = 0; ESP_LOGI(TAG, "Boot detected: %s", this->buffer_.substr(0, this->buffer_.size() - 2).c_str()); diff --git a/esphome/components/hyt271/__init__.py b/esphome/components/hyt271/__init__.py index 2e88d4f366..70be33a00e 100644 --- a/esphome/components/hyt271/__init__.py +++ b/esphome/components/hyt271/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Philippe12"] +DOMAIN = "hyt271" diff --git a/esphome/components/hyt271/hyt271.cpp b/esphome/components/hyt271/hyt271.cpp index 7b2c960a5f..a8374c4115 100644 --- a/esphome/components/hyt271/hyt271.cpp +++ b/esphome/components/hyt271/hyt271.cpp @@ -4,7 +4,9 @@ namespace esphome::hyt271 { -static const char *const TAG = "hyt271"; +ESPHOME_LOG_TAG(TAG, "hyt271"); + +static constexpr uint32_t WAIT_CONVERT_TIMEOUT_ID = 0; static const uint8_t HYT271_ADDRESS = 0x28; @@ -23,7 +25,7 @@ void HYT271Component::update() { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); return; } - this->set_timeout("wait_convert", 50, [this]() { + this->set_timeout(WAIT_CONVERT_TIMEOUT_ID, 50, [this]() { uint8_t raw_data[4]; if (this->read(raw_data, 4) != i2c::ERROR_OK) { this->status_set_warning(); 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/i2c/__init__.py b/esphome/components/i2c/__init__.py index b053125446..453c2513fa 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -38,7 +38,6 @@ from esphome.const import ( CONF_ADDRESS, CONF_DEVICE, CONF_FREQUENCY, - CONF_I2C, CONF_I2C_ID, CONF_ID, CONF_LOW_POWER_MODE, @@ -58,6 +57,7 @@ from esphome.cpp_generator import MockObj import esphome.final_validate as fv from esphome.types import ConfigType +DOMAIN = "i2c" LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] i2c_ns = cg.esphome_ns.namespace("i2c") @@ -232,7 +232,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config: ConfigType) -> None: - full_config = fv.full_config.get()[CONF_I2C] + full_config = fv.full_config.get()[DOMAIN] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") if CORE.is_rp2: diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 76b3ec3b4d..8fc0a50327 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -7,7 +7,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); void I2CBus::i2c_scan_() { for (uint8_t address = 8; address != 120; address++) { diff --git a/esphome/components/i2c/i2c_bus.h b/esphome/components/i2c/i2c_bus.h index 0c5e80bfe5..be38317497 100644 --- a/esphome/components/i2c/i2c_bus.h +++ b/esphome/components/i2c/i2c_bus.h @@ -4,6 +4,7 @@ #include #include +#include "esphome/core/defines.h" #include "esphome/core/helpers.h" namespace esphome::i2c { @@ -53,6 +54,15 @@ class I2CBus { return this->write_readv(address, buffer, len, nullptr, 0); } +#ifdef I2C_PORT_FREQUENCY_COUNT + /// @brief Switch the bus frequency for the transactions that follow; an external bus + /// that does not override this reports an error and keeps its frequency + virtual ErrorCode switch_frequency(uint32_t frequency) { return ERROR_UNKNOWN; } + + /// @brief The frequency the bus runs at + virtual uint32_t get_frequency() const { return 0; } +#endif + protected: /// @brief Scans the I2C bus for devices. Devices presence is kept in an array of std::pair /// that contains the address and the corresponding bool presence flag. diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 39a6aec774..215cc1364f 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -9,13 +9,13 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; void ArduinoI2CBus::setup() { - recover_(); + this->recover_(); #if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) @@ -25,9 +25,9 @@ void ArduinoI2CBus::setup() { // RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf // RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf if ((this->sda_pin_ / 2) % 2 == 0) { - wire_ = &Wire; + this->wire_ = &Wire; } else { - wire_ = &Wire1; + this->wire_ = &Wire1; } #endif @@ -42,22 +42,22 @@ void ArduinoI2CBus::setup() { void ArduinoI2CBus::set_pins_and_clock_() { #ifdef USE_RP2 - wire_->setSDA(this->sda_pin_); - wire_->setSCL(this->scl_pin_); - wire_->begin(); + this->wire_->setSDA(this->sda_pin_); + this->wire_->setSCL(this->scl_pin_); + this->wire_->begin(); #else - wire_->begin(static_cast(sda_pin_), static_cast(scl_pin_)); + this->wire_->begin(static_cast(this->sda_pin_), static_cast(this->scl_pin_)); #endif - if (timeout_ > 0) { // if timeout specified in yaml + if (this->timeout_ > 0) { // if timeout specified in yaml #if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h - wire_->setClockStretchLimit(timeout_); // unit: us + this->wire_->setClockStretchLimit(this->timeout_); // unit: us #elif defined(USE_RP2) // https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h - wire_->setTimeout(timeout_ / 1000); // unit: ms + this->wire_->setTimeout(this->timeout_ / 1000); // unit: ms #endif } - wire_->setClock(frequency_); + this->wire_->setClock(this->frequency_); } void ArduinoI2CBus::dump_config() { @@ -67,7 +67,7 @@ void ArduinoI2CBus::dump_config() { " SCL Pin: GPIO%u\n" " Frequency: %u Hz", this->sda_pin_, this->scl_pin_, this->frequency_); - if (timeout_ > 0) { + if (this->timeout_ > 0) { #if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); #elif defined(USE_RP2) @@ -87,10 +87,10 @@ void ArduinoI2CBus::dump_config() { } if (this->scan_) { ESP_LOGI(TAG, "Results from bus scan:"); - if (scan_results_.empty()) { + if (this->scan_results_.empty()) { ESP_LOGI(TAG, "Found no devices"); } else { - for (const auto &s : scan_results_) { + for (const auto &s : this->scan_results_) { if (s.second) { ESP_LOGI(TAG, "Found device at address 0x%02X", s.first); } else { @@ -106,7 +106,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe #if defined(USE_ESP8266) this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances #endif - if (!initialized_) { + if (!this->initialized_) { ESP_LOGD(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } @@ -118,22 +118,22 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe uint8_t status = 0; if (write_count != 0 || read_count == 0) { - wire_->beginTransmission(address); - size_t ret = wire_->write(write_buffer, write_count); + this->wire_->beginTransmission(address); + size_t ret = this->wire_->write(write_buffer, write_count); if (ret != write_count) { ESP_LOGV(TAG, "TX failed"); return ERROR_UNKNOWN; } - status = wire_->endTransmission(read_count == 0); + status = this->wire_->endTransmission(read_count == 0); } if (status == 0 && read_count != 0) { - size_t ret2 = wire_->requestFrom(address, read_count, true); + size_t ret2 = this->wire_->requestFrom(address, read_count, true); if (ret2 != read_count) { ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", read_count, address, ret2); return ERROR_TIMEOUT; } for (size_t j = 0; j != read_count; j++) - read_buffer[j] = wire_->read(); + read_buffer[j] = this->wire_->read(); } // Avoid switch to prevent compiler-generated lookup table in RAM on ESP8266 if (status == 0) @@ -154,6 +154,19 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe return ERROR_UNKNOWN; } +#ifdef I2C_PORT_FREQUENCY_COUNT +ErrorCode ArduinoI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + this->frequency_ = frequency; + if (this->initialized_) { + this->wire_->setClock(this->frequency_); + } + return ERROR_OK; +} +#endif + /// Perform I2C bus recovery, see: /// https://www.nxp.com/docs/en/user-guide/UM10204.pdf /// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf @@ -167,15 +180,15 @@ void ArduinoI2CBus::recover_() { const auto half_period_usec = 1000000 / 100000 / 2; // Activate input and pull up resistor for the SCL pin. - pinMode(scl_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT // This should make the signal on the line HIGH. If SCL is pulled low // on the I2C bus however, then some device is interfering with the SCL // line. In that case, the I2C bus cannot be recovered. delayMicroseconds(half_period_usec); - if (digitalRead(scl_pin_) == LOW) { // NOLINT + if (digitalRead(this->scl_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } @@ -189,18 +202,18 @@ void ArduinoI2CBus::recover_() { // Make sure that switching to output mode will make SCL low, just in // case other code has setup the pin for a HIGH signal. - digitalWrite(scl_pin_, LOW); // NOLINT + digitalWrite(this->scl_pin_, LOW); // NOLINT delayMicroseconds(half_period_usec); for (auto i = 0; i < 9; i++) { // Release pull up resistor and switch to output to make the signal LOW. - pinMode(scl_pin_, INPUT); // NOLINT - pinMode(scl_pin_, OUTPUT); // NOLINT + pinMode(this->scl_pin_, INPUT); // NOLINT + pinMode(this->scl_pin_, OUTPUT); // NOLINT delayMicroseconds(half_period_usec); // Release output and activate pull up resistor to make the signal HIGH. - pinMode(scl_pin_, INPUT); // NOLINT - pinMode(scl_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->scl_pin_, INPUT); // NOLINT + pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT delayMicroseconds(half_period_usec); // When SCL is kept LOW at this point, we might be looking at a device @@ -211,28 +224,28 @@ void ArduinoI2CBus::recover_() { // should recover in a few ms or less else not likely to recovery at // all. auto wait = 250; - while (wait-- && digitalRead(scl_pin_) == LOW) { // NOLINT + while (wait-- && digitalRead(this->scl_pin_) == LOW) { // NOLINT App.feed_wdt(); delayMicroseconds(half_period_usec * 2); } - if (digitalRead(scl_pin_) == LOW) { // NOLINT + if (digitalRead(this->scl_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } } // Activate input and pull resistor for the SDA pin, so we can verify // that SDA is pulled HIGH in the following step. - pinMode(sda_pin_, INPUT_PULLUP); // NOLINT - digitalWrite(sda_pin_, LOW); // NOLINT + pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT + digitalWrite(this->sda_pin_, LOW); // NOLINT // By now, any stuck device ought to have sent all remaining bits of its // transaction, meaning that it should have freed up the SDA line, resulting // in SDA being pulled up. - if (digitalRead(sda_pin_) == LOW) { // NOLINT + if (digitalRead(this->sda_pin_) == LOW) { // NOLINT ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SDA_LOW; + this->recovery_result_ = RECOVERY_FAILED_SDA_LOW; return; } @@ -248,8 +261,8 @@ void ArduinoI2CBus::recover_() { // SCL and SDA are already high at this point, so we can generate a START // condition by making the SDA signal LOW. delayMicroseconds(half_period_usec); - pinMode(sda_pin_, INPUT); // NOLINT - pinMode(sda_pin_, OUTPUT); // NOLINT + pinMode(this->sda_pin_, INPUT); // NOLINT + pinMode(this->sda_pin_, OUTPUT); // NOLINT // From the specification: // "A START condition immediately followed by a STOP condition (void @@ -258,10 +271,10 @@ void ArduinoI2CBus::recover_() { // Finally, we'll bring the I2C bus into a starting state by generating // a STOP condition. delayMicroseconds(half_period_usec); - pinMode(sda_pin_, INPUT); // NOLINT - pinMode(sda_pin_, INPUT_PULLUP); // NOLINT + pinMode(this->sda_pin_, INPUT); // NOLINT + pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT - recovery_result_ = RECOVERY_COMPLETED; + this->recovery_result_ = RECOVERY_COMPLETED; } } // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index 71e91e770b..9feea9f7ff 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -22,13 +22,17 @@ class ArduinoI2CBus final : public InternalI2CBus, public Component { size_t read_count) override; float get_setup_priority() const override { return setup_priority::BUS; } - void set_scan(bool scan) { scan_ = scan; } - void set_sda_pin(uint8_t sda_pin) { sda_pin_ = sda_pin; } - void set_scl_pin(uint8_t scl_pin) { scl_pin_ = scl_pin; } - void set_frequency(uint32_t frequency) { frequency_ = frequency; } - void set_timeout(uint32_t timeout) { timeout_ = timeout; } + void set_scan(bool scan) { this->scan_ = scan; } + void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } + void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } + void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } int get_port() const override { return 0; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#ifdef I2C_PORT_FREQUENCY_COUNT + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif private: void recover_(); diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index 7ca9537e2d..931bcfa088 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); // Maximum bytes to log in hex format (truncates larger transfers) static constexpr size_t I2C_MAX_LOG_BYTES = 32; @@ -32,8 +32,8 @@ void IDFI2CBus::setup() { i2c_master_bus_config_t bus_conf{}; memset(&bus_conf, 0, sizeof(bus_conf)); - bus_conf.sda_io_num = gpio_num_t(sda_pin_); - bus_conf.scl_io_num = gpio_num_t(scl_pin_); + bus_conf.sda_io_num = gpio_num_t(this->sda_pin_); + bus_conf.scl_io_num = gpio_num_t(this->scl_pin_); bus_conf.glitch_ignore_cnt = 7; #if SOC_LP_I2C_SUPPORTED if (this->lp_mode_) { @@ -59,7 +59,7 @@ void IDFI2CBus::setup() { } #endif bus_conf.i2c_port = this->port_; - bus_conf.flags.enable_internal_pullup = sda_pullup_enabled_ || scl_pullup_enabled_; + bus_conf.flags.enable_internal_pullup = this->sda_pullup_enabled_ || this->scl_pullup_enabled_; esp_err_t err = i2c_new_master_bus(&bus_conf, &this->bus_); if (err != ESP_OK) { ESP_LOGW(TAG, "i2c_new_master_bus failed: %s", esp_err_to_name(err)); @@ -67,18 +67,21 @@ void IDFI2CBus::setup() { return; } - i2c_device_config_t dev_conf{}; - memset(&dev_conf, 0, sizeof(dev_conf)); - dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7; - dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED; - dev_conf.scl_speed_hz = this->frequency_; - dev_conf.scl_wait_us = this->timeout_; - err = i2c_master_bus_add_device(this->bus_, &dev_conf, &this->dev_); - if (err != ESP_OK) { - ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + this->dev_ = this->add_device_(this->frequency_); + if (this->dev_ == nullptr) { this->mark_failed(); return; } +#ifdef I2C_PORT_FREQUENCY_COUNT + this->add_frequency(this->frequency_); + for (auto &device : this->devices_) { + device.dev = device.frequency == this->frequency_ ? this->dev_ : this->add_device_(device.frequency); + if (device.dev == nullptr) { + this->mark_failed(); + return; + } + } +#endif this->initialized_ = true; @@ -88,6 +91,21 @@ void IDFI2CBus::setup() { } } +i2c_master_dev_handle_t IDFI2CBus::add_device_(uint32_t frequency) { + i2c_device_config_t dev_conf{}; + dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7; + dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED; + dev_conf.scl_speed_hz = frequency; + dev_conf.scl_wait_us = this->timeout_; + i2c_master_dev_handle_t dev{}; + const esp_err_t err = i2c_master_bus_add_device(this->bus_, &dev_conf, &dev); + if (err != ESP_OK) { + ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err)); + return nullptr; + } + return dev; +} + void IDFI2CBus::dump_config() { ESP_LOGCONFIG(TAG, "I2C Bus:"); ESP_LOGCONFIG(TAG, @@ -95,7 +113,7 @@ void IDFI2CBus::dump_config() { " SCL Pin: GPIO%u\n" " Frequency: %" PRIu32 " Hz", this->sda_pin_, this->scl_pin_, this->frequency_); - if (timeout_ > 0) { + if (this->timeout_ > 0) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 "us", this->timeout_); } switch (this->recovery_result_) { @@ -111,10 +129,10 @@ void IDFI2CBus::dump_config() { } if (this->scan_) { ESP_LOGCONFIG(TAG, "Results from bus scan:"); - if (scan_results_.empty()) { + if (this->scan_results_.empty()) { ESP_LOGCONFIG(TAG, "Found no devices"); } else { - for (const auto &s : scan_results_) { + for (const auto &s : this->scan_results_) { if (s.second) { ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first); } else { @@ -129,7 +147,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s size_t read_count) { // logging is only enabled with v level, if warnings are shown the caller // should log them - if (!initialized_) { + if (!this->initialized_) { ESP_LOGW(TAG, "i2c bus not initialized!"); return ERROR_NOT_INITIALIZED; } @@ -198,14 +216,44 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s return ERROR_OK; } +#ifdef I2C_PORT_FREQUENCY_COUNT +void IDFI2CBus::add_frequency(uint32_t frequency) { + for (const auto &device : this->devices_) { + if (device.frequency == frequency) { + return; + } + } + this->devices_.push_back({frequency, nullptr}); +} + +ErrorCode IDFI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + if (!this->initialized_) { + this->frequency_ = frequency; + return ERROR_OK; + } + // Every handle exists since setup(); a frequency nobody registered has none + for (const auto &device : this->devices_) { + if (device.frequency == frequency) { + this->dev_ = device.dev; + this->frequency_ = frequency; + return ERROR_OK; + } + } + return ERROR_UNKNOWN; +} +#endif + /// Perform I2C bus recovery, see: /// https://www.nxp.com/docs/en/user-guide/UM10204.pdf /// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf void IDFI2CBus::recover_() { ESP_LOGI(TAG, "Performing bus recovery"); - const auto scl_pin = static_cast(scl_pin_); - const auto sda_pin = static_cast(sda_pin_); + const auto scl_pin = static_cast(this->scl_pin_); + const auto sda_pin = static_cast(this->sda_pin_); // For the upcoming operations, target for a 60kHz toggle frequency. // 1000kHz is the maximum frequency for I2C running in standard-mode, @@ -218,7 +266,7 @@ void IDFI2CBus::recover_() { // Configure SCL pin for open drain input/output, with a pull up resistor. gpio_set_level(scl_pin, 1); gpio_config_t scl_config{}; - scl_config.pin_bit_mask = 1ULL << scl_pin_; + scl_config.pin_bit_mask = 1ULL << this->scl_pin_; scl_config.mode = GPIO_MODE_INPUT_OUTPUT_OD; scl_config.pull_up_en = GPIO_PULLUP_ENABLE; scl_config.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -228,7 +276,7 @@ void IDFI2CBus::recover_() { // Configure SDA pin for open drain input/output, with a pull up resistor. gpio_set_level(sda_pin, 1); gpio_config_t sda_conf{}; - sda_conf.pin_bit_mask = 1ULL << sda_pin_; + sda_conf.pin_bit_mask = 1ULL << this->sda_pin_; sda_conf.mode = GPIO_MODE_INPUT_OUTPUT_OD; sda_conf.pull_up_en = GPIO_PULLUP_ENABLE; sda_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; @@ -240,7 +288,7 @@ void IDFI2CBus::recover_() { delayMicroseconds(half_period_usec); if (gpio_get_level(scl_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } @@ -271,7 +319,7 @@ void IDFI2CBus::recover_() { } if (gpio_get_level(scl_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SCL_LOW; + this->recovery_result_ = RECOVERY_FAILED_SCL_LOW; return; } } @@ -281,7 +329,7 @@ void IDFI2CBus::recover_() { // in SDA being pulled up. if (gpio_get_level(sda_pin) == 0) { ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle"); - recovery_result_ = RECOVERY_FAILED_SDA_LOW; + this->recovery_result_ = RECOVERY_FAILED_SDA_LOW; return; } @@ -308,7 +356,7 @@ void IDFI2CBus::recover_() { delayMicroseconds(half_period_usec); gpio_set_level(sda_pin, 1); - recovery_result_ = RECOVERY_COMPLETED; + this->recovery_result_ = RECOVERY_COMPLETED; } } // namespace esphome::i2c diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 92e96f649b..b9d8824d25 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -34,13 +34,28 @@ class IDFI2CBus final : public InternalI2CBus, public Component { #endif int get_port() const override { return this->port_; } +#ifdef I2C_PORT_FREQUENCY_COUNT + /// Register a frequency used on the bus so setup() creates its device handle + void add_frequency(uint32_t frequency); + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif private: void recover_(); + i2c_master_dev_handle_t add_device_(uint32_t frequency); RecoveryCode recovery_result_{}; protected: i2c_master_dev_handle_t dev_{}; +#ifdef I2C_PORT_FREQUENCY_COUNT + // One handle per frequency used on the bus, registered at code generation + struct FrequencyDevice { + uint32_t frequency; + i2c_master_dev_handle_t dev; + }; + StaticVector devices_; +#endif i2c_master_bus_handle_t bus_{}; i2c_port_t port_{}; uint8_t sda_pin_{}; diff --git a/esphome/components/i2c/i2c_bus_host.cpp b/esphome/components/i2c/i2c_bus_host.cpp index 303944636b..672bc9cc5d 100644 --- a/esphome/components/i2c/i2c_bus_host.cpp +++ b/esphome/components/i2c/i2c_bus_host.cpp @@ -16,7 +16,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); HostI2CBus::~HostI2CBus() { if (this->file_descriptor_ != -1) { diff --git a/esphome/components/i2c/i2c_bus_host.h b/esphome/components/i2c/i2c_bus_host.h index 8064016a52..086baab26c 100644 --- a/esphome/components/i2c/i2c_bus_host.h +++ b/esphome/components/i2c/i2c_bus_host.h @@ -21,7 +21,12 @@ class HostI2CBus final : public I2CBus, public Component { void set_device(const std::string &device) { this->device_ = device; } void set_scan(bool scan) { this->scan_ = scan; } + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#ifdef I2C_PORT_FREQUENCY_COUNT + // The kernel driver owns the clock, so switch_frequency keeps the base default + uint32_t get_frequency() const override { return this->frequency_; } +#endif const std::string &get_device() const { return this->device_; } diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index ffdd2ba8bb..ee6e66893e 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -6,7 +6,7 @@ namespace esphome::i2c { -static const char *const TAG = "i2c"; +ESPHOME_LOG_TAG(TAG, "i2c"); static const char *get_speed(uint32_t dev_config) { switch (I2C_SPEED_GET(dev_config)) { @@ -120,15 +120,34 @@ ErrorCode ZephyrI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer return ERROR_OK; } -void ZephyrI2CBus::set_frequency(uint32_t frequency) { - this->dev_config_ &= ~I2C_SPEED_MASK; - if (frequency >= 400000) { - this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_FAST); - } else { - this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_STANDARD); - } +static uint32_t speed_config(uint32_t dev_config, uint32_t frequency) { + const uint32_t speed = frequency >= 400000 ? I2C_SPEED_FAST : I2C_SPEED_STANDARD; + return (dev_config & ~I2C_SPEED_MASK) | I2C_SPEED_SET(speed); } +void ZephyrI2CBus::set_frequency(uint32_t frequency) { +#ifdef I2C_PORT_FREQUENCY_COUNT + this->frequency_ = frequency; +#endif + this->dev_config_ = speed_config(this->dev_config_, frequency); +} + +#ifdef I2C_PORT_FREQUENCY_COUNT +ErrorCode ZephyrI2CBus::switch_frequency(uint32_t frequency) { + if (this->frequency_ == frequency) { + return ERROR_OK; + } + const uint32_t dev_config = speed_config(this->dev_config_, frequency); + // Until ready, setup() applies dev_config_ + if (this->is_ready() && i2c_configure(this->i2c_dev_, dev_config) < 0) { + return ERROR_UNKNOWN; + } + this->dev_config_ = dev_config; + this->frequency_ = frequency; + return ERROR_OK; +} +#endif + } // namespace esphome::i2c #endif diff --git a/esphome/components/i2c/i2c_bus_zephyr.h b/esphome/components/i2c/i2c_bus_zephyr.h index 3ada1e0a0f..413d78d6a3 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.h +++ b/esphome/components/i2c/i2c_bus_zephyr.h @@ -22,6 +22,10 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component { void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; } void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; } void set_frequency(uint32_t frequency); +#ifdef I2C_PORT_FREQUENCY_COUNT + ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override { return this->frequency_; } +#endif int get_port() const override { return 0; } @@ -31,6 +35,9 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component { uint8_t sda_pin_{}; uint8_t scl_pin_{}; uint32_t dev_config_{}; +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t frequency_{}; +#endif }; } // namespace esphome::i2c diff --git a/esphome/components/i2c_device/__init__.py b/esphome/components/i2c_device/__init__.py index f890fefdb9..a118cb3f17 100644 --- a/esphome/components/i2c_device/__init__.py +++ b/esphome/components/i2c_device/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@gabest11"] +DOMAIN = "i2c_device" MULTI_CONF = True i2c_device_ns = cg.esphome_ns.namespace("i2c_device") diff --git a/esphome/components/i2c_device/i2c_device.cpp b/esphome/components/i2c_device/i2c_device.cpp index 2f68a9e96a..379a25574e 100644 --- a/esphome/components/i2c_device/i2c_device.cpp +++ b/esphome/components/i2c_device/i2c_device.cpp @@ -5,7 +5,7 @@ namespace esphome::i2c_device { -static const char *const TAG = "i2c_device"; +ESPHOME_LOG_TAG(TAG, "i2c_device"); void I2CDeviceComponent::dump_config() { ESP_LOGCONFIG(TAG, "I2CDevice"); diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index c5e82beb46..6e4e35590c 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -18,6 +18,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) +from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE from esphome.core import CORE @@ -27,6 +28,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["esp32"] +DOMAIN = "i2s_audio" MULTI_CONF = True CONF_PDM = "pdm" @@ -39,7 +41,6 @@ CONF_I2S_MCLK_PIN = "i2s_mclk_pin" CONF_I2S_BCLK_PIN = "i2s_bclk_pin" CONF_I2S_LRCLK_PIN = "i2s_lrclk_pin" -CONF_I2S_AUDIO = "i2s_audio" CONF_I2S_AUDIO_ID = "i2s_audio_id" CONF_I2S_MODE = "i2s_mode" @@ -218,9 +219,9 @@ class I2SAudioData: def _get_data() -> I2SAudioData: - if CONF_I2S_AUDIO not in CORE.data: - CORE.data[CONF_I2S_AUDIO] = I2SAudioData() - return CORE.data[CONF_I2S_AUDIO] + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = I2SAudioData() + return CORE.data[DOMAIN] def _assign_ports() -> None: @@ -234,12 +235,12 @@ def _assign_ports() -> None: return full_config = fv.full_config.get() - i2s_configs = full_config[CONF_I2S_AUDIO] + i2s_configs = full_config[DOMAIN] # Find i2s_audio instances with microphones that require port 0 # (PDM and internal ADC only work on I2S port 0) port0_parent_id = None - for mic_config in full_config.get("microphone", []): + for mic_config in full_config.get(MICROPHONE_DOMAIN, []): if CONF_I2S_AUDIO_ID not in mic_config: continue if mic_config.get(CONF_PDM) or mic_config.get(CONF_ADC_TYPE) == "internal": @@ -262,7 +263,7 @@ def _assign_ports() -> None: def _final_validate(_: ConfigType) -> None: - i2s_audio_configs = fv.full_config.get()[CONF_I2S_AUDIO] + i2s_audio_configs = fv.full_config.get()[DOMAIN] variant = get_esp32_variant() if variant not in I2S_PORTS: raise cv.Invalid(f"Unsupported variant {variant}") diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index c577ed092e..1345f0187b 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -19,7 +19,7 @@ static const uint32_t READ_DURATION_MS = 16; static const size_t TASK_STACK_SIZE = 4096; static const ssize_t TASK_PRIORITY = 23; -static const char *const TAG = "i2s_audio.microphone"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.microphone"); enum MicrophoneEventGroupBits : uint32_t { COMMAND_STOP = (1 << 0), // stops the microphone task, set and cleared by ``loop`` @@ -397,7 +397,7 @@ void I2SAudioMicrophone::loop() { if (!this->start_driver_()) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); - this->status_momentary_error("driver_fail", 1000); + this->status_momentary_error(1000); this->stop_driver_(); // Stop/frees whatever possibly started break; } @@ -408,7 +408,7 @@ void I2SAudioMicrophone::loop() { if (this->task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task_fail", 1000); + this->status_momentary_error(1000); this->stop_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } } 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 ec4e459be7..5054478ceb 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_spdif.cpp @@ -14,7 +14,7 @@ namespace esphome::i2s_audio { -static const char *const TAG = "i2s_audio.spdif"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.spdif"); // SPDIF mode adds overhead as each sample is encapsulated in a subframe; // each DMA buffer can hold only 192 samples (~4ms each vs. ~15ms for standard I2S). diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index cb82b09f33..8d586ea4e0 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -18,7 +18,7 @@ namespace esphome::i2s_audio { -static const char *const TAG = "i2s_audio.speaker"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.speaker"); // 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. @@ -40,6 +40,16 @@ void I2SAudioSpeakerBase::setup() { // When no audio_dac is configured, this initializes software volume control. this->set_volume(this->volume_); this->set_mute_state(this->mute_state_); + + // Until the I2S driver first starts, dout sits in its reset state (often pulled high, or a JTAG + // function on the ESP32-S3), which keeps a SPDIF optical transmitter lit. Park it low now. + this->park_dout_pin_(); +} + +void I2SAudioSpeakerBase::park_dout_pin_() { + gpio_reset_pin(this->dout_pin_); + gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); + gpio_set_level(this->dout_pin_, 0); } void I2SAudioSpeakerBase::dump_config() { @@ -77,6 +87,12 @@ void I2SAudioSpeakerBase::loop() { ESP_LOGV(TAG, "Started"); xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING); this->state_ = speaker::STATE_RUNNING; +#ifdef USE_AUDIO_DAC + // The task raises TASK_RUNNING only after enabling the channel, so the clocks are already running + if (this->audio_dac_ != nullptr) { + this->audio_dac_->on_audio_started(); + } +#endif // USE_AUDIO_DAC } if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) { ESP_LOGV(TAG, "Stopping"); @@ -124,7 +140,7 @@ void I2SAudioSpeakerBase::loop() { if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); - this->status_momentary_error("driver-failure", 1000); + this->status_momentary_error(1000); break; } @@ -137,7 +153,7 @@ void I2SAudioSpeakerBase::loop() { if (this->speaker_task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); - this->status_momentary_error("task-failure", 1000); + this->status_momentary_error(1000); this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt } break; @@ -297,9 +313,7 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() { // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - gpio_reset_pin(this->dout_pin_); - gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT); - gpio_set_level(this->dout_pin_, 0); + this->park_dout_pin_(); } this->parent_->unlock(); } diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index b443166ea1..965eede020 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -129,6 +129,9 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public /// @brief Stops the I2S driver and unlocks the I2S port void stop_i2s_driver_(); + /// @brief Detaches dout from the I2S signal and drives it low + void park_dout_pin_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index b4b6173458..fbc1edbde9 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -18,7 +18,7 @@ namespace esphome::i2s_audio { -static const char *const TAG = "i2s_audio.speaker.std"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.speaker.std"); static constexpr uint32_t DMA_BUFFER_DURATION_MS = 10; static constexpr size_t DMA_BUFFERS_COUNT = 5; diff --git a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp index 30146e0a70..c98cbcb20a 100644 --- a/esphome/components/i2s_audio/speaker/spdif_encoder.cpp +++ b/esphome/components/i2s_audio/speaker/spdif_encoder.cpp @@ -6,7 +6,7 @@ namespace esphome::i2s_audio { -static const char *const TAG = "i2s_audio.spdif_encoder"; +ESPHOME_LOG_TAG(TAG, "i2s_audio.spdif_encoder"); // S/PDIF preamble patterns (8 BMC bits each) // These are the BMC-encoded sync patterns that violate normal BMC rules for easy detection. diff --git a/esphome/components/i2s_clockless_led_strip/__init__.py b/esphome/components/i2s_clockless_led_strip/__init__.py new file mode 100644 index 0000000000..038e865f3a --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/__init__.py @@ -0,0 +1 @@ +DOMAIN = "i2s_clockless_led_strip" diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp new file mode 100644 index 0000000000..bf53d96a83 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.cpp @@ -0,0 +1,254 @@ +#include "i2s_clockless_led_strip.h" + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/helpers.h" + +#include +#include + +namespace esphome::i2s_clockless_led_strip { + +ESPHOME_LOG_TAG(TAG, "i2s_clockless_led_strip"); + +constexpr const char *const ERROR_ALLOCATION = "Allocation error"; +constexpr const char *const ERROR_I2S = "I2S error"; + +// These values are hardcoded for an LED strip at 800 kbps. +// +// Each bit of LED strip data gets expanded into 3 bits of I2S data, where a 0-bit expands into 100 +// (1/3 duty cycle) and a 1-bit expands into 110 (2/3 duty cycle. Each byte of LED strip data becomes +// one 24-bit I2S sample to be transmitted at a rate of 100000 samples per second in I2S TDM mode. +constexpr uint32_t I2S_SAMPLE_RATE_HZ = 100000; +constexpr size_t I2S_BYTES_PER_SAMPLE = 3; + +// Determine how many I2S samples are needed to feed the output for the specified duration and vice-versa. +constexpr size_t num_i2s_samples_from_duration_us(uint32_t duration_us) { + return (duration_us * I2S_SAMPLE_RATE_HZ + 999999) / 1000000; +} +constexpr uint32_t num_i2s_samples_to_duration_us(size_t samples) { return samples * 1000000ull / I2S_SAMPLE_RATE_HZ; } + +// Determine the number of I2S samples to pad with zeros after the LED data to encode the LED strip reset signal. +// Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5. +constexpr uint32_t I2S_RESET_DURATION_US = 300; +constexpr size_t I2S_RESET_SAMPLES = num_i2s_samples_from_duration_us(I2S_RESET_DURATION_US); +constexpr size_t I2S_RESET_BYTES = I2S_RESET_SAMPLES * I2S_BYTES_PER_SAMPLE; + +// Determine the number of I2S samples per DMA buffer to ensure that the interrupt-driven I2S on_sent callback +// doesn't run too frequently. For example, if the buffers were sized to hold just 1 RGBW pixel then they would be +// recycled approximately every 40 us (25000 Hz) which is way too fast. The buffer duration also imposes an upper +// bound on the maximum LED strip refresh rate. Round up to a multiple of 3 for `dma_frame_num` as required by +// the ESP-IDF programming guide when using 24-bit samples. +constexpr uint32_t I2S_BUFFER_DURATION_US_IDEAL = 2500; +constexpr size_t I2S_BUFFER_SAMPLES = (num_i2s_samples_from_duration_us(I2S_BUFFER_DURATION_US_IDEAL) + 2) / 3 * 3; +constexpr uint32_t I2S_BUFFER_DURATION_US_ACTUAL = num_i2s_samples_to_duration_us(I2S_BUFFER_SAMPLES); + +I2SClocklessLedStrip::I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors) + : color_data_bytes_(num_leds * channel_colors.bytes_per_led()), + channel_colors_(channel_colors), + num_leds_(num_leds), + pin_(pin) {} + +void I2SClocklessLedStrip::dump_config() { + ESP_LOGCONFIG(TAG, + "I2S Clockless LED Strip:\n" + " Pin: %u", + this->pin_); + char channel_colors[5]; + ESP_LOGCONFIG(TAG, + " Channel colors: %s\n" + " Number of LEDs: %" PRIu16 "\n" + " DMA buffer: %" PRIuPTR " samples (%" PRIu32 " us)", + this->channel_colors_.to_string(channel_colors), this->num_leds_, I2S_BUFFER_SAMPLES, + I2S_BUFFER_DURATION_US_ACTUAL); +} + +float I2SClocklessLedStrip::get_setup_priority() const { return setup_priority::IO; } + +void I2SClocklessLedStrip::setup() { + if (!this->allocate_buffers_()) { + this->mark_failed(LOG_STR(ERROR_ALLOCATION)); + return; + } + + i2s_chan_config_t chan_config = { + .id = I2S_NUM_AUTO, + .role = I2S_ROLE_MASTER, + .dma_desc_num = 2, + .dma_frame_num = I2S_BUFFER_SAMPLES, + .auto_clear_after_cb = false, + .auto_clear_before_cb = false, + .allow_pd = false, + .intr_priority = 0, + }; + esp_err_t err = i2s_new_channel(&chan_config, &this->tx_handle_, NULL); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_new_channel: %s", esp_err_to_name(err)); + } else { + i2s_tdm_config_t tdm_config = { + .clk_cfg = + { + .sample_rate_hz = I2S_SAMPLE_RATE_HZ, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_384, + }, + .slot_cfg = + { + .data_bit_width = I2S_DATA_BIT_WIDTH_24BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = I2S_TDM_SLOT0, + .ws_width = 1, + .big_endian = true, + .total_slot = I2S_TDM_AUTO_SLOT_NUM, + }, + .gpio_cfg = + { + .mclk = I2S_GPIO_UNUSED, + .bclk = I2S_GPIO_UNUSED, + .ws = I2S_GPIO_UNUSED, + .dout = gpio_num_t(this->pin_), + .din = I2S_GPIO_UNUSED, + .invert_flags = + { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + err = i2s_channel_init_tdm_mode(this->tx_handle_, &tdm_config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_init_tdm_mode: %s", esp_err_to_name(err)); + } else { + i2s_event_callbacks_t event_callbacks = { + .on_sent = i2s_on_sent_callback, + }; + err = i2s_channel_register_event_callback(this->tx_handle_, &event_callbacks, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Error in i2s_channel_register_event_callback: %s", esp_err_to_name(err)); + } else { + err = i2s_channel_enable(this->tx_handle_); + if (err == ESP_OK) + return; // Success! + ESP_LOGE(TAG, "Error in i2s_channel_enable: %s", esp_err_to_name(err)); + } + } + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = {}; + } + this->mark_failed(LOG_STR(ERROR_I2S)); +} + +bool I2SClocklessLedStrip::allocate_buffers_() { + const size_t i2s_data_bytes = this->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + + RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); + this->i2s_data_ = allocator.allocate(i2s_data_bytes); + if (this->i2s_data_ != nullptr) { + this->color_data_ = allocator.allocate(this->color_data_bytes_); + if (this->color_data_ != nullptr) { + this->effect_data_ = allocator.allocate(this->num_leds_); + if (this->effect_data_ != nullptr) { + memset(this->i2s_data_, 0, i2s_data_bytes); + memset(this->color_data_, 0, this->color_data_bytes_); + memset(this->effect_data_, 0, this->num_leds_); + return true; + } + allocator.deallocate(this->color_data_, this->color_data_bytes_); + this->color_data_ = nullptr; + } + allocator.deallocate(this->i2s_data_, i2s_data_bytes); + this->i2s_data_ = nullptr; + } + return false; +} + +light::LightTraits I2SClocklessLedStrip::get_traits() { + auto traits = light::LightTraits(); + if (this->channel_colors_.has_white()) { + traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); + } else { + traits.set_supported_color_modes({light::ColorMode::RGB}); + } + return traits; +} + +void I2SClocklessLedStrip::write_state(light::LightState *state) { + if (this->is_failed()) + return; + + if (this->i2s_data_ready_.load(std::memory_order_acquire)) { + // Busy sending the last frame, try again later. + this->schedule_show(); + return; + } + + const size_t color_data_bytes = this->color_data_bytes_; + uint8_t *color_data = this->color_data_; + uint8_t *i2s_data = this->i2s_data_; + for (size_t i = 0; i < color_data_bytes; i++) { + const uint8_t color_byte = *(color_data++); + *(i2s_data++) = 0b10010010 | ((color_byte & 0x80) >> 1) | ((color_byte & 0x40) >> 3) | ((color_byte & 0x20) >> 5); + *(i2s_data++) = 0b01001001 | ((color_byte & 0x10) << 1) | ((color_byte & 0x08) >> 1); + *(i2s_data++) = 0b00100100 | ((color_byte & 0x04) << 5) | ((color_byte & 0x02) << 3) | ((color_byte & 0x01) << 1); + } + + this->i2s_data_sent_ = 0; + this->i2s_data_ready_.store(true, std::memory_order_release); + + this->mark_shown_(); +} + +bool IRAM_ATTR HOT I2SClocklessLedStrip::i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, + void *user_ctx) { + auto *const self = static_cast(user_ctx); + auto *const dma_buf = static_cast(event->dma_buf); + const size_t dma_buf_size = event->size; + + bool dma_buf_filled = false; + if (self->i2s_data_ready_.load(std::memory_order_acquire)) { + const size_t i2s_data_bytes = self->color_data_bytes_ * I2S_BYTES_PER_SAMPLE; + if (self->i2s_data_sent_ < i2s_data_bytes) { + const size_t i2s_data_remaining = i2s_data_bytes - self->i2s_data_sent_; + if (i2s_data_remaining >= dma_buf_size) { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, dma_buf_size); + } else { + memcpy(dma_buf, self->i2s_data_ + self->i2s_data_sent_, i2s_data_remaining); + memset(dma_buf + i2s_data_remaining, 0, dma_buf_size - i2s_data_remaining); + } + dma_buf_filled = true; + } + self->i2s_data_sent_ += dma_buf_size; + if (self->i2s_data_sent_ >= i2s_data_bytes + I2S_RESET_BYTES) { + self->i2s_data_ready_.store(false, std::memory_order_release); + } + } + // We only need to clear the buffer when the first byte is non-zero. Zero is not a value + // that can appear within the I2S data that gets copied into the front of the buffer so + // if the first byte is zero then the buffer must have already been cleared. + if (!dma_buf_filled && dma_buf[0] != 0) { + memset(dma_buf, 0, dma_buf_size); + } + return false; +} + +void I2SClocklessLedStrip::clear_effect_data() { memset(this->effect_data_, 0, this->num_leds_); } + +light::ESPColorView I2SClocklessLedStrip::get_view_internal(int32_t index) const { + const light::ChannelColors &colors = this->channel_colors_; + uint8_t *led = this->color_data_ + (index * colors.bytes_per_led()); + return {led + colors.r, + led + colors.g, + led + colors.b, + colors.has_white() ? led + colors.w : nullptr, + &this->effect_data_[index], + &this->correction_}; +} + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h new file mode 100644 index 0000000000..0c516a6bc8 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/i2s_clockless_led_strip.h @@ -0,0 +1,56 @@ +#pragma once + +#ifdef USE_ESP32 +#include +#endif + +#if defined(USE_ESP32) && SOC_I2S_SUPPORTS_TDM + +#include +#include + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/channel_colors.h" + +#include + +namespace esphome::i2s_clockless_led_strip { + +class I2SClocklessLedStrip final : public light::AddressableLight { + public: + I2SClocklessLedStrip(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors); + + void dump_config() override; + + float get_setup_priority() const override; + void setup() override; + + light::LightTraits get_traits() override; + + int32_t size() const override { return this->num_leds_; } + void clear_effect_data() override; + void write_state(light::LightState *state) override; + + protected: + static bool i2s_on_sent_callback(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx); + + bool allocate_buffers_(); + light::ESPColorView get_view_internal(int32_t index) const override; + + const size_t color_data_bytes_; + uint8_t *effect_data_{nullptr}; + uint8_t *color_data_{nullptr}; + uint8_t *i2s_data_{nullptr}; + size_t i2s_data_sent_{0}; + i2s_chan_handle_t tx_handle_{}; + const light::ChannelColors channel_colors_; + const uint16_t num_leds_; + const uint8_t pin_; + std::atomic i2s_data_ready_{false}; +}; + +} // namespace esphome::i2s_clockless_led_strip + +#endif diff --git a/esphome/components/i2s_clockless_led_strip/light.py b/esphome/components/i2s_clockless_led_strip/light.py new file mode 100644 index 0000000000..90a4f1c354 --- /dev/null +++ b/esphome/components/i2s_clockless_led_strip/light.py @@ -0,0 +1,86 @@ +from dataclasses import dataclass + +from esphome import pins +import esphome.codegen as cg +from esphome.components import esp32, light +from esphome.components.const import CONF_CHANNEL_COLORS +import esphome.config_validation as cv +from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID, CONF_PIN +from esphome.core import CORE +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@j9brown"] +DEPENDENCIES = ["esp32"] + +i2s_clockless_led_strip_ns = cg.esphome_ns.namespace("i2s_clockless_led_strip") +I2SClocklessLedStrip = i2s_clockless_led_strip_ns.class_( + "I2SClocklessLedStrip", light.AddressableLight +) + +# These variants define SOC_I2S_SUPPORTS_TDM +I2S_TDM_PORTS = { + esp32.VARIANT_ESP32C3: 1, + esp32.VARIANT_ESP32C5: 1, + esp32.VARIANT_ESP32C6: 1, + esp32.VARIANT_ESP32C61: 1, + esp32.VARIANT_ESP32H2: 1, + esp32.VARIANT_ESP32H4: 1, + esp32.VARIANT_ESP32P4: 3, + esp32.VARIANT_ESP32S3: 2, +} + +CONFIG_SCHEMA = cv.All( + esp32.only_on_variant( + supported=list(I2S_TDM_PORTS.keys()), + msg_prefix="I2S Clockless LED Strip", + ), + light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(I2SClocklessLedStrip), + cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, + cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors, + } + ).extend(cv.COMPONENT_SCHEMA), +) + + +@dataclass +class I2SClocklessLedStripData: + num_ports: int = 0 + + +def _get_data() -> I2SClocklessLedStripData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = I2SClocklessLedStripData() + return CORE.data[DOMAIN] + + +def _final_validate(_: ConfigType) -> None: + _get_data().num_ports += 1 + variant = esp32.get_esp32_variant() + num_ports_available = I2S_TDM_PORTS.get(variant, 0) + if _get_data().num_ports > num_ports_available: + raise cv.Invalid( + f"Only {num_ports_available} I2S TDM ports are supported on {variant}" + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + esp32.include_builtin_idf_component("esp_driver_i2s") + esp32.add_idf_sdkconfig_option("CONFIG_I2S_ISR_IRAM_SAFE", True) + + var = cg.new_Pvariable( + config[CONF_OUTPUT_ID], + config[CONF_PIN], + config[CONF_NUM_LEDS], + light.channel_colors_struct(config[CONF_CHANNEL_COLORS]), + ) + + await light.register_light(var, config) + await cg.register_component(var, config) diff --git a/esphome/components/iaqcore/__init__.py b/esphome/components/iaqcore/__init__.py index e69de29bb2..216921250f 100644 --- a/esphome/components/iaqcore/__init__.py +++ b/esphome/components/iaqcore/__init__.py @@ -0,0 +1 @@ +DOMAIN = "iaqcore" diff --git a/esphome/components/iaqcore/iaqcore.cpp b/esphome/components/iaqcore/iaqcore.cpp index 7397d0975c..48935c2468 100644 --- a/esphome/components/iaqcore/iaqcore.cpp +++ b/esphome/components/iaqcore/iaqcore.cpp @@ -5,7 +5,7 @@ namespace esphome::iaqcore { -static const char *const TAG = "iaqcore"; +ESPHOME_LOG_TAG(TAG, "iaqcore"); enum IAQCoreErrorCode : uint8_t { ERROR_OK = 0, ERROR_RUNIN = 0x10, ERROR_BUSY = 0x01, ERROR_ERROR = 0x80 }; 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 index 07f3b4e31c..7736bfd393 100644 --- a/esphome/components/icnt86/__init__.py +++ b/esphome/components/icnt86/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@danepowell"] +DOMAIN = "icnt86" diff --git a/esphome/components/icnt86/icnt86.cpp b/esphome/components/icnt86/icnt86.cpp index 62a4586ebc..42585269f8 100644 --- a/esphome/components/icnt86/icnt86.cpp +++ b/esphome/components/icnt86/icnt86.cpp @@ -3,7 +3,7 @@ namespace esphome::icnt86 { -static const char *const TAG = "icnt86"; +ESPHOME_LOG_TAG(TAG, "icnt86"); static constexpr uint16_t REG_TOUCH_NUM = 0x1001; static constexpr uint16_t REG_POINT1 = 0x1002; static constexpr uint8_t MAX_TOUCHES = 5; diff --git a/esphome/components/ili9341/__init__.py b/esphome/components/ili9341/__init__.py index e69de29bb2..95ff0c3056 100644 --- a/esphome/components/ili9341/__init__.py +++ b/esphome/components/ili9341/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ili9341" diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index 84888bbabc..2803b43915 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -1,3 +1,5 @@ +DOMAIN = "ili9xxx" + DEPRECATED_COMPONENT = """ The 'ili9xxx' component is deprecated and no new models will be added to it. New model PRs should target the newer and more performant 'mipi_spi' component. diff --git a/esphome/components/improv_base/__init__.py b/esphome/components/improv_base/__init__.py index 9b57b6561f..1acc5e6ca0 100644 --- a/esphome/components/improv_base/__init__.py +++ b/esphome/components/improv_base/__init__.py @@ -8,6 +8,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "improv_base" CONF_NEXT_URL = "next_url" diff --git a/esphome/components/improv_base/improv_base.cpp b/esphome/components/improv_base/improv_base.cpp index 6745f8064b..e25980545c 100644 --- a/esphome/components/improv_base/improv_base.cpp +++ b/esphome/components/improv_base/improv_base.cpp @@ -9,7 +9,7 @@ namespace esphome::improv_base { #ifdef USE_IMPROV_NEXT_URL -static const char *const TAG = "improv_base"; +ESPHOME_LOG_TAG(TAG, "improv_base"); static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}"; static constexpr size_t DEVICE_NAME_PLACEHOLDER_LEN = sizeof(DEVICE_NAME_PLACEHOLDER) - 1; diff --git a/esphome/components/improv_ble/__init__.py b/esphome/components/improv_ble/__init__.py index 72ac586628..e7d4be8cc3 100644 --- a/esphome/components/improv_ble/__init__.py +++ b/esphome/components/improv_ble/__init__.py @@ -1,14 +1,9 @@ from esphome import automation import esphome.codegen as cg from esphome.components import binary_sensor, improv_base, output +from esphome.components.const import CONF_ON_STOP import esphome.config_validation as cv -from esphome.const import ( - CONF_ID, - CONF_ON_START, - CONF_ON_STATE, - CONF_TRIGGER_ID, - PLATFORM_ESP32, -) +from esphome.const import CONF_ID, CONF_ON_START, CONF_ON_STATE, PLATFORM_ESP32 from esphome.core import CORE from esphome.types import ConfigType @@ -31,6 +26,7 @@ def AUTO_LOAD() -> list[str]: CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["wifi"] +DOMAIN = "improv_ble" # Legacy top-level YAML key that routes here; esphome/loader.py and # esphome/config.py handle the warning and the key rename. @@ -43,7 +39,6 @@ CONF_BLE_SERVER_ID = "ble_server_id" CONF_IDENTIFY_DURATION = "identify_duration" CONF_ON_PROVISIONED = "on_provisioned" CONF_ON_PROVISIONING = "on_provisioning" -CONF_ON_STOP = "on_stop" CONF_STATUS_INDICATOR = "status_indicator" CONF_WIFI_TIMEOUT = "wifi_timeout" @@ -58,20 +53,35 @@ State = improv_ns.enum("State") 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() -) -ImprovBLEProvisioningTrigger = improv_ble_ns.class_( - "ImprovBLEProvisioningTrigger", automation.Trigger.template() -) -ImprovBLEStartTrigger = improv_ble_ns.class_( - "ImprovBLEStartTrigger", automation.Trigger.template() -) -ImprovBLEStateTrigger = improv_ble_ns.class_( - "ImprovBLEStateTrigger", automation.Trigger.template() -) -ImprovBLEStoppedTrigger = improv_ble_ns.class_( - "ImprovBLEStoppedTrigger", automation.Trigger.template() + +_STATE_PARAMS = [(State, "state"), (Error, "error")] +_NOT_FAILED = "!{parent}->is_failed()" + + +def _state_automation( + conf_key: str, args: list, states: str | None +) -> automation.CallbackAutomation: + # Every automation hangs off the state callback and is silent while the component has failed. + return automation.CallbackAutomation( + conf_key, + "add_on_state_callback", + args, + params=_STATE_PARAMS, + forward=[name for _, name in args], + when=_NOT_FAILED if states is None else f"({states}) && {_NOT_FAILED}", + ) + + +_CALLBACK_AUTOMATIONS = ( + _state_automation(CONF_ON_PROVISIONED, [], "state == improv::STATE_PROVISIONED"), + _state_automation(CONF_ON_PROVISIONING, [], "state == improv::STATE_PROVISIONING"), + _state_automation( + CONF_ON_START, + [], + "state == improv::STATE_AUTHORIZED || state == improv::STATE_AWAITING_AUTHORIZATION", + ), + _state_automation(CONF_ON_STATE, _STATE_PARAMS, None), + _state_automation(CONF_ON_STOP, [], "state == improv::STATE_STOPPED"), ) @@ -92,41 +102,11 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_WIFI_TIMEOUT, default=DEFAULT_WIFI_TIMEOUT ): cv.positive_time_period_milliseconds, - cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEProvisionedTrigger - ), - } - ), - cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEProvisioningTrigger - ), - } - ), - cv.Optional(CONF_ON_START): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStartTrigger - ), - } - ), - cv.Optional(CONF_ON_STATE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStateTrigger - ), - } - ), - cv.Optional(CONF_ON_STOP): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - ImprovBLEStoppedTrigger - ), - } - ), + cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation({}), + cv.Optional(CONF_ON_PROVISIONING): automation.validate_automation({}), + cv.Optional(CONF_ON_START): automation.validate_automation({}), + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + cv.Optional(CONF_ON_STOP): automation.validate_automation({}), } ) .extend(improv_base.IMPROV_SCHEMA) @@ -163,28 +143,6 @@ async def to_code(config: ConfigType) -> None: status_indicator = await cg.get_variable(config[CONF_STATUS_INDICATOR]) cg.add(var.set_status_indicator(status_indicator)) - use_state_callback = False - for conf in config.get(CONF_ON_PROVISIONED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_PROVISIONING, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_START, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - for conf in config.get(CONF_ON_STATE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(State, "state"), (Error, "error")], conf - ) - use_state_callback = True - for conf in config.get(CONF_ON_STOP, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - use_state_callback = True - if use_state_callback: + if any(config.get(entry.conf_key) for entry in _CALLBACK_AUTOMATIONS): cg.add_define("USE_IMPROV_BLE_STATE_CALLBACK") + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/improv_ble/automation.h b/esphome/components/improv_ble/automation.h deleted file mode 100644 index 223a129238..0000000000 --- a/esphome/components/improv_ble/automation.h +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once -#ifdef USE_ESP32 -#ifdef USE_IMPROV_BLE_STATE_CALLBACK -#include "improv_ble_component.h" - -#include "esphome/core/automation.h" - -#include - -namespace esphome::improv_ble { - -class ImprovBLEProvisionedTrigger final : public Trigger<> { - public: - 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(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEProvisioningTrigger final : public Trigger<> { - public: - 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(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStartTrigger final : public Trigger<> { - public: - 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()) { - this->trigger(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStateTrigger final : public Trigger { - public: - 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); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -class ImprovBLEStoppedTrigger final : public Trigger<> { - public: - 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(); - } - }); - } - - protected: - ImprovBLEComponent *parent_; -}; - -} // namespace esphome::improv_ble - -#endif -#endif diff --git a/esphome/components/improv_ble/improv_ble_component.cpp b/esphome/components/improv_ble/improv_ble_component.cpp index 0a20beb33c..2029058899 100644 --- a/esphome/components/improv_ble/improv_ble_component.cpp +++ b/esphome/components/improv_ble/improv_ble_component.cpp @@ -20,7 +20,11 @@ namespace esphome::improv_ble { using namespace bytebuffer; -static const char *const TAG = "improv_ble.component"; +ESPHOME_LOG_TAG(TAG, "improv_ble.component"); + +static constexpr uint32_t STOP_SERVICE_TIMEOUT_ID = 0; +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 1; + 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 @@ -322,7 +326,7 @@ void ImprovBLEComponent::stop() { // 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 // them to observe that the device is provisioned before the service disappears. - this->set_timeout("end-service", STOP_ADVERTISING_DELAY, [this] { + this->set_timeout(STOP_SERVICE_TIMEOUT_ID, STOP_ADVERTISING_DELAY, [this] { if (this->state_ == improv::STATE_STOPPED || this->service_ == nullptr) return; // Release first so removing the service UUID does not restart advertising on the way out @@ -398,7 +402,7 @@ void ImprovBLEComponent::process_incoming_data_() { ESP_LOGD(TAG, "Received Improv Wi-Fi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", 30000, [this]() { this->on_wifi_connect_timeout_(); }); + this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, 30000, [this]() { this->on_wifi_connect_timeout_(); }); this->incoming_data_.clear(); break; } @@ -438,7 +442,7 @@ void ImprovBLEComponent::check_wifi_connection_() { if (this->state_ == improv::STATE_PROVISIONING) { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID); // Build the URL list directly into a stack buffer with no heap allocation std::array buf; diff --git a/esphome/components/improv_ble/improv_ble_component.h b/esphome/components/improv_ble/improv_ble_component.h index 2552bed69b..126663ed00 100644 --- a/esphome/components/improv_ble/improv_ble_component.h +++ b/esphome/components/improv_ble/improv_ble_component.h @@ -72,9 +72,6 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas improv::Error get_improv_error_state() const { return this->error_state_; } protected: - bool should_start_{false}; - bool setup_complete_{false}; - uint32_t identify_start_{0}; uint32_t identify_duration_; uint32_t authorized_start_{0}; @@ -84,6 +81,14 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas std::vector incoming_data_; wifi::WiFiAP connecting_sta_; + // Single byte fields here fill the alignment gap between connecting_sta_ and the next pointer + improv::State state_{improv::STATE_STOPPED}; + improv::Error error_state_{improv::ERROR_NONE}; + bool should_start_{false}; + bool setup_complete_{false}; + bool status_indicator_state_{false}; + bool advertising_device_name_{false}; + bool advertising_requested_{false}; BLEService *service_{nullptr}; BLECharacteristic *status_{nullptr}; @@ -99,16 +104,11 @@ class ImprovBLEComponent final : public Component, public improv_base::ImprovBas output::BinaryOutput *status_indicator_{nullptr}; #endif - improv::State state_{improv::STATE_STOPPED}; - improv::Error error_state_{improv::ERROR_NONE}; #ifdef USE_IMPROV_BLE_STATE_CALLBACK CallbackManager state_callback_{}; #endif - bool status_indicator_state_{false}; uint32_t last_name_adv_time_{0}; - bool advertising_device_name_{false}; - bool advertising_requested_{false}; void set_status_indicator_state_(bool state); void update_advertising_type_(); void request_advertising_(); diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index 0231791e9b..3e1aef21bb 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -1,15 +1,9 @@ import esphome.codegen as cg from esphome.components import improv_base, uart from esphome.components.esp32 import VARIANT_ESP32S3, get_esp32_variant -from esphome.components.logger import USB_CDC +from esphome.components.logger import DOMAIN as LOGGER_DOMAIN, USB_CDC import esphome.config_validation as cv -from esphome.const import ( - CONF_BAUD_RATE, - CONF_HARDWARE_UART, - CONF_ID, - CONF_LOGGER, - CONF_UART_ID, -) +from esphome.const import CONF_BAUD_RATE, CONF_HARDWARE_UART, CONF_ID, CONF_UART_ID from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType @@ -17,6 +11,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["improv_base"] CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["logger", "network"] +DOMAIN = "improv_serial" improv_serial_ns = cg.esphome_ns.namespace("improv_serial") @@ -52,7 +47,7 @@ def validate_transport(config: ConfigType) -> None: # The host logger has no serial port for Improv to share if CORE.is_host: raise cv.Invalid("improv_serial on the host platform requires uart_id") - logger_conf = fv.full_config.get()[CONF_LOGGER] + logger_conf = fv.full_config.get()[LOGGER_DOMAIN] if logger_conf[CONF_BAUD_RATE] == 0: raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0") if CORE.is_esp32 and ( diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 3827fb6ed4..906781e17b 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -16,7 +16,9 @@ namespace esphome::improv_serial { -static const char *const TAG = "improv_serial"; +ESPHOME_LOG_TAG(TAG, "improv_serial"); + +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_ID = 0; void ImprovSerialComponent::setup() { global_improv_serial_component = this; @@ -73,7 +75,7 @@ void ImprovSerialComponent::loop() { wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(), this->connecting_sta_.get_password()); this->connecting_sta_ = {}; - this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout(WIFI_CONNECT_TIMEOUT_ID); this->set_state_(improv::STATE_PROVISIONED); this->send_settings_response_(improv::WIFI_SETTINGS); @@ -93,16 +95,9 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) const uint8_t header_checksum_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE - 2; // Only transmit the full buffer length if there is no data (only state/error byte is provided in this case) const uint8_t header_tx_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE; - // Calculate checksum for message - uint8_t checksum = 0; - for (uint8_t i = 0; i < header_checksum_len; i++) { - checksum += this->tx_header_[i]; - } + uint8_t checksum = sum_bytes(this->tx_header_, header_checksum_len); if (there_is_data) { - // Include data in checksum - for (size_t i = 0; i < size; i++) { - checksum += data[i]; - } + checksum = sum_bytes(data, size, checksum); } this->tx_header_[TX_CHECKSUM_IDX] = checksum; @@ -302,7 +297,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); - this->set_timeout("wifi-connect-timeout", switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, + this->set_timeout(WIFI_CONNECT_TIMEOUT_ID, switching ? WIFI_SWITCH_TIMEOUT_MS : WIFI_CONNECT_TIMEOUT_MS, [this]() { this->on_wifi_connect_timeout_(); }); #else // No Wi-Fi support compiled in; there is nothing to provision. diff --git a/esphome/components/ina219/__init__.py b/esphome/components/ina219/__init__.py index e69de29bb2..5e9d2cef17 100644 --- a/esphome/components/ina219/__init__.py +++ b/esphome/components/ina219/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina219" diff --git a/esphome/components/ina219/ina219.cpp b/esphome/components/ina219/ina219.cpp index 833d1989c8..a675099b4d 100644 --- a/esphome/components/ina219/ina219.cpp +++ b/esphome/components/ina219/ina219.cpp @@ -4,7 +4,7 @@ namespace esphome::ina219 { -static const char *const TAG = "ina219"; +ESPHOME_LOG_TAG(TAG, "ina219"); // | A0 | A1 | Address | // | GND | GND | 0x40 | 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/__init__.py b/esphome/components/ina226/__init__.py index ca1f2caa31..9998623c82 100644 --- a/esphome/components/ina226/__init__.py +++ b/esphome/components/ina226/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Sergio303", "@latonita"] +DOMAIN = "ina226" diff --git a/esphome/components/ina226/ina226.cpp b/esphome/components/ina226/ina226.cpp index c22237d144..97b3caf949 100644 --- a/esphome/components/ina226/ina226.cpp +++ b/esphome/components/ina226/ina226.cpp @@ -5,7 +5,7 @@ namespace esphome::ina226 { -static const char *const TAG = "ina226"; +ESPHOME_LOG_TAG(TAG, "ina226"); // | A0 | A1 | Address | // | GND | GND | 0x40 | diff --git a/esphome/components/ina226/sensor.py b/esphome/components/ina226/sensor.py index 4fd98fbcd4..53427fc63e 100644 --- a/esphome/components/ina226/sensor.py +++ b/esphome/components/ina226/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 CONF_ADC_AVERAGING, CONF_ADC_TIME import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -24,8 +25,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_ADC_AVERAGING = "adc_averaging" -CONF_ADC_TIME = "adc_time" ina226_ns = cg.esphome_ns.namespace("ina226") INA226Component = ina226_ns.class_( @@ -133,18 +132,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/__init__.py b/esphome/components/ina260/__init__.py index e69de29bb2..4728ef80f8 100644 --- a/esphome/components/ina260/__init__.py +++ b/esphome/components/ina260/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina260" diff --git a/esphome/components/ina260/ina260.cpp b/esphome/components/ina260/ina260.cpp index 05039f0e33..82a4e4fba1 100644 --- a/esphome/components/ina260/ina260.cpp +++ b/esphome/components/ina260/ina260.cpp @@ -4,7 +4,7 @@ namespace esphome::ina260 { -static const char *const TAG = "ina260"; +ESPHOME_LOG_TAG(TAG, "ina260"); // | A0 | A1 | Address | // | GND | GND | 0x40 | 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 474283ccce..c6239ef6d2 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -2,7 +2,7 @@ from typing import Any import esphome.codegen as cg from esphome.components import sensor -from esphome.components.const import UNIT_AMPERE_HOUR +from esphome.components.const import CONF_ADC_AVERAGING, CONF_ADC_TIME, UNIT_AMPERE_HOUR import esphome.config_validation as cv from esphome.const import ( CONF_BUS_VOLTAGE, @@ -34,10 +34,9 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@latonita"] +DOMAIN = "ina2xx_base" -CONF_ADC_AVERAGING = "adc_averaging" CONF_ADC_RANGE = "adc_range" -CONF_ADC_TIME = "adc_time" CONF_CHARGE = "charge" CONF_CHARGE_COULOMBS = "charge_coulombs" CONF_ENERGY_JOULES = "energy_joules" @@ -225,38 +224,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/ina2xx_base/ina2xx_base.cpp b/esphome/components/ina2xx_base/ina2xx_base.cpp index fec5cd2f13..58e0598b06 100644 --- a/esphome/components/ina2xx_base/ina2xx_base.cpp +++ b/esphome/components/ina2xx_base/ina2xx_base.cpp @@ -7,7 +7,7 @@ namespace esphome::ina2xx_base { -static const char *const TAG = "ina2xx"; +ESPHOME_LOG_TAG(TAG, "ina2xx"); #define OKFAILED(b) ((b) ? "OK" : "FAILED") diff --git a/esphome/components/ina2xx_i2c/__init__.py b/esphome/components/ina2xx_i2c/__init__.py index e69de29bb2..d6c784bad7 100644 --- a/esphome/components/ina2xx_i2c/__init__.py +++ b/esphome/components/ina2xx_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina2xx_i2c" diff --git a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp index 4fc3b00a21..61c7744cca 100644 --- a/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp +++ b/esphome/components/ina2xx_i2c/ina2xx_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::ina2xx_i2c { -static const char *const TAG = "ina2xx_i2c"; +ESPHOME_LOG_TAG(TAG, "ina2xx_i2c"); void INA2XXI2C::setup() { auto err = this->write(nullptr, 0); diff --git a/esphome/components/ina2xx_spi/__init__.py b/esphome/components/ina2xx_spi/__init__.py index e69de29bb2..ae72c2d195 100644 --- a/esphome/components/ina2xx_spi/__init__.py +++ b/esphome/components/ina2xx_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina2xx_spi" diff --git a/esphome/components/ina2xx_spi/ina2xx_spi.cpp b/esphome/components/ina2xx_spi/ina2xx_spi.cpp index 43eb676236..b9b7a6c774 100644 --- a/esphome/components/ina2xx_spi/ina2xx_spi.cpp +++ b/esphome/components/ina2xx_spi/ina2xx_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::ina2xx_spi { -static const char *const TAG = "ina2xx_spi"; +ESPHOME_LOG_TAG(TAG, "ina2xx_spi"); void INA2XXSPI::setup() { this->spi_setup(); diff --git a/esphome/components/ina3221/__init__.py b/esphome/components/ina3221/__init__.py index e69de29bb2..f8f491ddec 100644 --- a/esphome/components/ina3221/__init__.py +++ b/esphome/components/ina3221/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ina3221" diff --git a/esphome/components/ina3221/ina3221.cpp b/esphome/components/ina3221/ina3221.cpp index b88480fc00..f3647c4d1c 100644 --- a/esphome/components/ina3221/ina3221.cpp +++ b/esphome/components/ina3221/ina3221.cpp @@ -4,7 +4,7 @@ namespace esphome::ina3221 { -static const char *const TAG = "ina3221"; +ESPHOME_LOG_TAG(TAG, "ina3221"); static const uint8_t INA3221_REGISTER_CONFIG = 0x00; static const uint8_t INA3221_REGISTER_CHANNEL1_SHUNT_VOLTAGE = 0x01; diff --git a/esphome/components/infrared/__init__.py b/esphome/components/infrared/__init__.py index d04c82ea96..2d3d478e58 100644 --- a/esphome/components/infrared/__init__.py +++ b/esphome/components/infrared/__init__.py @@ -9,20 +9,22 @@ 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"] +DOMAIN = "infrared" 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 +54,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 83039a5a9b..9b766a8996 100644 --- a/esphome/components/infrared/infrared.cpp +++ b/esphome/components/infrared/infrared.cpp @@ -1,161 +1,18 @@ #include "infrared.h" -#include - #include "esphome/core/log.h" -#ifdef USE_API -#include "esphome/components/api/api_server.h" -#endif - namespace esphome::infrared { -static const char *const TAG = "infrared"; - -// ========== InfraredCall ========== - -InfraredCall &InfraredCall::set_carrier_frequency(uint32_t frequency) { - this->carrier_frequency_ = frequency; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings(const std::vector &timings) { - this->raw_timings_ = &timings; - this->packed_data_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings_base64url(const std::string &base64url) { - this->base64url_ptr_ = &base64url; - this->raw_timings_ = nullptr; - this->packed_data_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count) { - this->packed_data_ = data; - this->packed_length_ = length; - this->packed_count_ = count; - this->raw_timings_ = nullptr; - this->base64url_ptr_ = nullptr; - return *this; -} - -InfraredCall &InfraredCall::set_repeat_count(uint32_t count) { - this->repeat_count_ = count; - return *this; -} - -void InfraredCall::perform() { - if (this->parent_ != nullptr) { - this->parent_->control(*this); - } -} - -// ========== Infrared ========== - -void Infrared::setup() { - // Set up traits based on configuration - this->traits_.set_supports_transmitter(this->has_transmitter()); - this->traits_.set_supports_receiver(this->has_receiver()); -} +ESPHOME_LOG_TAG(TAG, "infrared"); 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 afbde57be2..d81e3e2e38 100644 --- a/esphome/components/infrared/infrared.h +++ b/esphome/components/infrared/infrared.h @@ -4,131 +4,48 @@ // without following the normal breaking changes policy. Use at your own risk. // Once the API is considered stable, this warning will be removed. -#include "esphome/core/component.h" -#include "esphome/core/entity_base.h" -#include "esphome/components/remote_base/remote_base.h" - -#include +#include "esphome/components/ir_rf_base/ir_rf_base.h" namespace esphome::infrared { -/// Capability flags for individual infrared instances -enum InfraredCapability : uint32_t { - CAPABILITY_TRANSMITTER = 1 << 0, // Can transmit signals - CAPABILITY_RECEIVER = 1 << 1, // Can receive signals -}; +using ir_rf_base::CAPABILITY_RECEIVER; +using ir_rf_base::CAPABILITY_TRANSMITTER; -/// Forward declarations class Infrared; /// InfraredCall - Builder pattern for transmitting infrared signals -class InfraredCall { +class InfraredCall : public ir_rf_base::IrRfCall { public: - explicit InfraredCall(Infrared *parent) : parent_(parent) {} + explicit InfraredCall(Infrared *parent) : IrRfCall(parent) {} /// Set the carrier frequency in Hz - InfraredCall &set_carrier_frequency(uint32_t frequency); - - // ===== Raw Timings Methods ===== - // All set_raw_timings_* methods store pointers/references to external data. - // The referenced data must remain valid until perform() completes. - // Safe pattern: call.set_raw_timings_xxx(data); call.perform(); // synchronous - // Unsafe pattern: call.set_raw_timings_xxx(data); defer([call]() { call.perform(); }); // data may be gone! - - /// Set the raw timings from a vector (positive = mark, negative = space) - /// @note Lifetime: Stores a pointer to the vector. The vector must outlive perform(). - /// @note Usage: Primarily for lambdas/automations where the vector is in scope. - InfraredCall &set_raw_timings(const std::vector &timings); - - /// Set the raw timings from base64url-encoded little-endian int32 data - /// @note Lifetime: Stores a pointer to the string. The string must outlive perform(). - /// @note Usage: For web_server - base64url is fully URL-safe (uses '-' and '_'). - /// @note Decoding happens at perform() time, directly into the transmit buffer. - InfraredCall &set_raw_timings_base64url(const std::string &base64url); - - /// Set the raw timings from packed protobuf sint32 data (zigzag + varint encoded) - /// @note Lifetime: Stores a pointer to the buffer. The buffer must outlive perform(). - /// @note Usage: For API component where data comes directly from the protobuf message. - InfraredCall &set_raw_timings_packed(const uint8_t *data, uint16_t length, uint16_t count); - - /// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.) - InfraredCall &set_repeat_count(uint32_t count); - - /// Perform the transmission - void perform(); - + InfraredCall &set_carrier_frequency(uint32_t frequency) { + this->carrier_frequency_ = frequency; + return *this; + } /// Get the carrier frequency const optional &get_carrier_frequency() const { return this->carrier_frequency_; } - /// Get the raw timings (only valid if set via set_raw_timings) - const std::vector &get_raw_timings() const { return *this->raw_timings_; } - /// Check if raw timings have been set (any format) - bool has_raw_timings() const { - return this->raw_timings_ != nullptr || this->packed_data_ != nullptr || this->base64url_ptr_ != nullptr; - } - /// Check if using packed data format - bool is_packed() const { return this->packed_data_ != nullptr; } - /// Check if using base64url data format - bool is_base64url() const { return this->base64url_ptr_ != nullptr; } - /// Get the base64url data string - const std::string &get_base64url_data() const { return *this->base64url_ptr_; } - /// Get packed data (only valid if set via set_raw_timings_packed) - const uint8_t *get_packed_data() const { return this->packed_data_; } - uint16_t get_packed_length() const { return this->packed_length_; } - uint16_t get_packed_count() const { return this->packed_count_; } - /// Get the repeat count - uint32_t get_repeat_count() const { return this->repeat_count_; } protected: - uint32_t repeat_count_{1}; - Infrared *parent_; optional carrier_frequency_; - // Pointer to vector-based timings (caller-owned, must outlive perform()) - const std::vector *raw_timings_{nullptr}; - // Pointer to base64url-encoded string (caller-owned, must outlive perform()) - const std::string *base64url_ptr_{nullptr}; - // Pointer to packed protobuf buffer (caller-owned, must outlive perform()) - const uint8_t *packed_data_{nullptr}; - uint16_t packed_length_{0}; - uint16_t packed_count_{0}; }; /// InfraredTraits - Describes the capabilities of an infrared implementation class InfraredTraits { public: - bool get_supports_transmitter() const { return this->supports_transmitter_; } - void set_supports_transmitter(bool supports) { this->supports_transmitter_ = supports; } - - bool get_supports_receiver() const { return this->supports_receiver_; } - void set_supports_receiver(bool supports) { this->supports_receiver_ = supports; } - uint32_t get_receiver_frequency_hz() const { return this->receiver_frequency_hz_; } void set_receiver_frequency_hz(uint32_t freq) { this->receiver_frequency_hz_ = freq; } protected: - bool supports_transmitter_{false}; - bool supports_receiver_{false}; uint32_t receiver_frequency_hz_{0}; // Demodulation frequency of the IR receiver in Hz (0 = unspecified) }; /// Infrared - Base class for infrared remote control implementations -class Infrared : public Component, public EntityBase, public remote_base::RemoteReceiverListener { +class Infrared : public ir_rf_base::IrRfEntity { public: Infrared() = default; - void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } - - /// Set the remote receiver component; the listener registration happens from codegen, see - /// remote_base.attach_receiver - void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } - /// Set the remote transmitter component - void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - - /// Check if this infrared has a transmitter configured - bool has_transmitter() const { return this->transmitter_ != nullptr; } - /// Check if this infrared has a receiver configured - bool has_receiver() const { return this->receiver_ != nullptr; } /// Get the traits for this infrared implementation InfraredTraits &get_traits() { return this->traits_; } @@ -137,21 +54,16 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote /// Create a call object for transmitting InfraredCall make_call() { return InfraredCall(this); } - /// Get capability flags for this infrared instance - uint32_t get_capability_flags() const; - - /// Called when IR data is received (from RemoteReceiverListener) - bool on_receive(remote_base::RemoteReceiveData data) override; - protected: - friend class InfraredCall; + friend class ir_rf_base::IrRfCall; - /// Perform the actual transmission (called by InfraredCall) - virtual void control(const InfraredCall &call); - - // Underlying hardware components - remote_base::RemoteReceiverBase *receiver_{nullptr}; - remote_base::RemoteTransmitterBase *transmitter_{nullptr}; + void on_call_(const InfraredCall &) {} + /// Perform the actual transmission (called by InfraredCall); false only when no frame was handed + /// to the transmitter, in which case no completion follows + /// Without a remote_base transmitter, call api_transmit_done_() once the frame is out + virtual bool control(const InfraredCall &call) { + return this->transmit_raw_(call, call.get_carrier_frequency().value_or(0)); + } // Traits describing capabilities InfraredTraits traits_; diff --git a/esphome/components/inkbird_ibsth1_mini/__init__.py b/esphome/components/inkbird_ibsth1_mini/__init__.py index e69de29bb2..e0fbed17ca 100644 --- a/esphome/components/inkbird_ibsth1_mini/__init__.py +++ b/esphome/components/inkbird_ibsth1_mini/__init__.py @@ -0,0 +1 @@ +DOMAIN = "inkbird_ibsth1_mini" diff --git a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp index d360142bcb..ef6600b4eb 100644 --- a/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp +++ b/esphome/components/inkbird_ibsth1_mini/inkbird_ibsth1_mini.cpp @@ -3,7 +3,7 @@ namespace esphome::inkbird_ibsth1_mini { -static const char *const TAG = "inkbird_ibsth1_mini"; +ESPHOME_LOG_TAG(TAG, "inkbird_ibsth1_mini"); void InkbirdIbstH1Mini::dump_config() { ESP_LOGCONFIG(TAG, "Inkbird IBS TH1 MINI"); 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/inkplate/__init__.py b/esphome/components/inkplate/__init__.py index 1c6013793a..36740332a5 100644 --- a/esphome/components/inkplate/__init__.py +++ b/esphome/components/inkplate/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jesserockz", "@JosipKuci"] +DOMAIN = "inkplate" diff --git a/esphome/components/inkplate/inkplate.cpp b/esphome/components/inkplate/inkplate.cpp index 2e837fb614..b1e8535d20 100644 --- a/esphome/components/inkplate/inkplate.cpp +++ b/esphome/components/inkplate/inkplate.cpp @@ -9,7 +9,7 @@ namespace esphome::inkplate { -static const char *const TAG = "inkplate"; +ESPHOME_LOG_TAG(TAG, "inkplate"); void Inkplate::setup() { for (uint32_t i = 0; i < 256; i++) { diff --git a/esphome/components/inkplate6/__init__.py b/esphome/components/inkplate6/__init__.py index e69de29bb2..2b198f666b 100644 --- a/esphome/components/inkplate6/__init__.py +++ b/esphome/components/inkplate6/__init__.py @@ -0,0 +1 @@ +DOMAIN = "inkplate6" diff --git a/esphome/components/integration/__init__.py b/esphome/components/integration/__init__.py index 71a87b6ae5..4afc7ce9e1 100644 --- a/esphome/components/integration/__init__.py +++ b/esphome/components/integration/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "integration" diff --git a/esphome/components/integration/integration_sensor.cpp b/esphome/components/integration/integration_sensor.cpp index bc26fb0f19..c7b6173e39 100644 --- a/esphome/components/integration/integration_sensor.cpp +++ b/esphome/components/integration/integration_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::integration { -static const char *const TAG = "integration"; +ESPHOME_LOG_TAG(TAG, "integration"); void IntegrationSensor::setup() { if (this->restore_) { 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/__init__.py b/esphome/components/internal_temperature/__init__.py index 9433ade13f..aa1671a015 100644 --- a/esphome/components/internal_temperature/__init__.py +++ b/esphome/components/internal_temperature/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Mat931"] +DOMAIN = "internal_temperature" diff --git a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp index 91f47d831f..00f3b97ec0 100644 --- a/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp +++ b/esphome/components/internal_temperature/internal_temperature_bk72xx.cpp @@ -9,7 +9,7 @@ uint32_t temp_single_get_current_temperature(uint32_t *temp_value); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_common.cpp b/esphome/components/internal_temperature/internal_temperature_common.cpp index 89a7d34333..51c55d4adb 100644 --- a/esphome/components/internal_temperature/internal_temperature_common.cpp +++ b/esphome/components/internal_temperature/internal_temperature_common.cpp @@ -3,7 +3,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); void InternalTemperatureSensor::dump_config() { LOG_SENSOR("", "Internal Temperature Sensor", this); } diff --git a/esphome/components/internal_temperature/internal_temperature_esp32.cpp b/esphome/components/internal_temperature/internal_temperature_esp32.cpp index 2c6fda2af4..4da062ac05 100644 --- a/esphome/components/internal_temperature/internal_temperature_esp32.cpp +++ b/esphome/components/internal_temperature/internal_temperature_esp32.cpp @@ -16,7 +16,7 @@ uint8_t temprature_sens_read(); namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); void InternalTemperatureSensor::update() { float temperature = NAN; diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index c4ab33b0a5..b0ccf99145 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -16,7 +16,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); // The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 // and RP2350A, but input 8 on RP2350B, which has eight external channels rather diff --git a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp index 50c597f6f1..5c3e81d10f 100644 --- a/esphome/components/internal_temperature/internal_temperature_zephyr.cpp +++ b/esphome/components/internal_temperature/internal_temperature_zephyr.cpp @@ -8,7 +8,7 @@ namespace esphome::internal_temperature { -static const char *const TAG = "internal_temperature"; +ESPHOME_LOG_TAG(TAG, "internal_temperature"); static const struct device *const DIE_TEMPERATURE_SENSOR = DEVICE_DT_GET_ONE(nordic_nrf_temp); diff --git a/esphome/components/interval/__init__.py b/esphome/components/interval/__init__.py index 11c3e15b0d..adbe4349c6 100644 --- a/esphome/components/interval/__init__.py +++ b/esphome/components/interval/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID, CONF_INTERVAL, CONF_STARTUP_DELAY from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "interval" interval_ns = cg.esphome_ns.namespace("interval") IntervalTrigger = interval_ns.class_( "IntervalTrigger", automation.Trigger.template(), cg.PollingComponent diff --git a/esphome/components/ir_rf_base/__init__.py b/esphome/components/ir_rf_base/__init__.py new file mode 100644 index 0000000000..7a4cde3677 --- /dev/null +++ b/esphome/components/ir_rf_base/__init__.py @@ -0,0 +1,39 @@ +"""Shared base for the infrared and radio_frequency entity components.""" + +import esphome.codegen as cg +from esphome.components import remote_base +from esphome.components.api import DOMAIN as API_DOMAIN +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"] +DOMAIN = "ir_rf_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 API_DOMAIN 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..91db4252c0 --- /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 { + +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/ir_rf_proxy/__init__.py index bc4079ede7..45bce4ac6f 100644 --- a/esphome/components/ir_rf_proxy/__init__.py +++ b/esphome/components/ir_rf_proxy/__init__.py @@ -3,6 +3,7 @@ import esphome.codegen as cg CODEOWNERS = ["@kbx81"] +DOMAIN = "ir_rf_proxy" # Namespace and constants exported for infrared.py platform ir_rf_proxy_ns = cg.esphome_ns.namespace("ir_rf_proxy") diff --git a/esphome/components/ir_rf_proxy/infrared.py b/esphome/components/ir_rf_proxy/infrared.py index 288bd91673..548ba2b132 100644 --- a/esphome/components/ir_rf_proxy/infrared.py +++ b/esphome/components/ir_rf_proxy/infrared.py @@ -3,12 +3,7 @@ from typing import Any import esphome.codegen as cg -from esphome.components import ( - infrared, - remote_base, - remote_receiver, - remote_transmitter, -) +from esphome.components import infrared, ir_rf_base, remote_base, remote_receiver from esphome.components.const import CONF_RECEIVER_FREQUENCY import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY @@ -30,7 +25,7 @@ CONFIG_SCHEMA = cv.All( remote_receiver.RemoteReceiverComponent ), cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent + remote_base.RemoteTransmitterBase ), } ), @@ -82,8 +77,7 @@ async def to_code(config: dict[str, Any]) -> None: # Link transmitter if specified if CONF_REMOTE_TRANSMITTER_ID in config: - transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID) # Link receiver if specified if CONF_REMOTE_RECEIVER_ID in config: diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp index ceb4c9a67c..0eae11d1a5 100644 --- a/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp +++ b/esphome/components/ir_rf_proxy/ir_rf_proxy.cpp @@ -6,72 +6,19 @@ 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(); -} +ESPHOME_LOG_TAG(TAG, "ir_rf_proxy"); // ========== IrRfProxy (Infrared platform) ========== -#ifdef USE_IR_RF +#ifdef USE_INFRARED void IrRfProxy::dump_config() { ESP_LOGCONFIG(TAG, "IR Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); if (this->is_rf()) { ESP_LOGCONFIG(TAG, " Hardware Type: RF (%.3f MHz)", this->frequency_khz_ / 1e3f); @@ -80,21 +27,14 @@ void IrRfProxy::dump_config() { } } -void IrRfProxy::control(const infrared::InfraredCall &call) { - uint32_t carrier = call.get_carrier_frequency().value_or(0); - transmit_raw_timings(this->transmitter_, carrier, call); -} - -#endif // USE_IR_RF +#endif // USE_INFRARED // ========== RfProxy (Radio Frequency platform) ========== #ifdef USE_RADIO_FREQUENCY void RfProxy::setup() { - this->traits_.set_supports_transmitter(this->transmitter_ != nullptr); - this->traits_.set_supports_receiver(this->receiver_ != nullptr); - + ir_rf_base::IrRfEntity::setup(); // remote_transmitter/receiver always uses OOK (on-off keying) this->traits_.add_supported_modulation(radio_frequency::RadioFrequencyModulation::RADIO_FREQUENCY_MODULATION_OOK); } @@ -104,8 +44,8 @@ void RfProxy::dump_config() { "RF Proxy '%s'\n" " Supports Transmitter: %s\n" " Supports Receiver: %s", - this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()), - YESNO(this->traits_.get_supports_receiver())); + this->get_name().c_str(), YESNO(this->get_supports_transmitter()), + YESNO(this->get_supports_receiver())); const auto &traits = this->traits_; if (traits.get_frequency_min_hz() > 0) { @@ -118,11 +58,11 @@ void RfProxy::dump_config() { } } -void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { +bool RfProxy::control(const radio_frequency::RadioFrequencyCall &call) { // RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning) // happens via the radio_frequency entity's on_control trigger and remote_transmitter's // on_transmit/on_complete triggers — wired up in user YAML. - transmit_raw_timings(this->transmitter_, 0, call); + return this->transmit_raw_(call, 0); } #endif // USE_RADIO_FREQUENCY diff --git a/esphome/components/ir_rf_proxy/ir_rf_proxy.h b/esphome/components/ir_rf_proxy/ir_rf_proxy.h index 1aa4394fe8..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,20 +52,11 @@ class RfProxy final : public radio_frequency::RadioFrequency { void setup() override; void dump_config() override; - /// Set the remote transmitter component - void set_transmitter(remote_base::RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - /// Set the remote receiver component; the listener registration happens from codegen, see - /// remote_base.attach_receiver - void set_receiver(remote_base::RemoteReceiverBase *receiver) { this->receiver_ = receiver; } - /// Set the fixed carrier frequency in Hz (metadata: advertised via traits, does not tune hardware) void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); } protected: - void control(const radio_frequency::RadioFrequencyCall &call) override; - - remote_base::RemoteTransmitterBase *transmitter_{nullptr}; - remote_base::RemoteReceiverBase *receiver_{nullptr}; + bool control(const radio_frequency::RadioFrequencyCall &call) override; }; #endif // USE_RADIO_FREQUENCY diff --git a/esphome/components/ir_rf_proxy/radio_frequency.py b/esphome/components/ir_rf_proxy/radio_frequency.py index 28b8fd5953..1519fe9908 100644 --- a/esphome/components/ir_rf_proxy/radio_frequency.py +++ b/esphome/components/ir_rf_proxy/radio_frequency.py @@ -1,12 +1,7 @@ """Radio Frequency platform implementation using remote_base (remote_transmitter/receiver).""" import esphome.codegen as cg -from esphome.components import ( - radio_frequency, - remote_base, - remote_receiver, - remote_transmitter, -) +from esphome.components import ir_rf_base, radio_frequency, remote_base, remote_receiver import esphome.config_validation as cv from esphome.const import CONF_CARRIER_DUTY_PERCENT, CONF_FREQUENCY import esphome.final_validate as fv @@ -27,7 +22,7 @@ CONFIG_SCHEMA = cv.All( remote_receiver.RemoteReceiverComponent ), cv.Optional(CONF_REMOTE_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent + remote_base.RemoteTransmitterBase ), } ), @@ -67,8 +62,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_frequency_hz(int(config[CONF_FREQUENCY]))) if CONF_REMOTE_TRANSMITTER_ID in config: - transmitter = await cg.get_variable(config[CONF_REMOTE_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) + await ir_rf_base.attach_transmitter(var, config, CONF_REMOTE_TRANSMITTER_ID) if CONF_REMOTE_RECEIVER_ID in config: await remote_base.attach_receiver(var, config, CONF_REMOTE_RECEIVER_ID) diff --git a/esphome/components/it8951/__init__.py b/esphome/components/it8951/__init__.py index 7fc4ae2cd0..94bdf451d3 100644 --- a/esphome/components/it8951/__init__.py +++ b/esphome/components/it8951/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Passific", "@koosoli", "@limengdu"] +DOMAIN = "it8951" diff --git a/esphome/components/it8951/display.py b/esphome/components/it8951/display.py index 57bf86c4c6..76fbadd196 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. @@ -146,8 +145,8 @@ IT8951Model( reset_pin=12, cs_pin=10, # Board power-enable rails: 1.8V logic supply (GPIO21) and the EPD supply - # (GPIO11). Driven high during setup so no separate power_supply is needed. - enable_pin=[21, 11], + # (GPIO11). Driven high during setup so no separate power_supply is needed, and held low during deep_sleep. + enable_pin=[{"number": 21, "hold_state": True}, {"number": 11, "hold_state": True}], vcom=1400, # reTerminal E1003 panel firmware only accepts the 0x0002 VCOM SET # selector; using the default 0x0001 leaves VCOM unchanged and breaks @@ -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 237f1c3c8b..fe99017774 100644 --- a/esphome/components/it8951/it8951.cpp +++ b/esphome/components/it8951/it8951.cpp @@ -10,7 +10,7 @@ namespace esphome::it8951 { -static const char *const TAG = "it8951"; +ESPHOME_LOG_TAG(TAG, "it8951"); // Soft cap for time spent in a single XFER_ROWS Op so we yield back to the // loop within one tick budget. @@ -351,6 +351,12 @@ void IT8951Display::on_safe_shutdown() { this->spi_cmd_(TCON_SLEEP); } +void IT8951Display::on_powerdown() { + for (auto *pin : this->enable_pins_) { + pin->digital_write(false); + } +} + // --- Init op enqueuers ------------------------------------------------------- void IT8951Display::enqueue_init_reset_() { @@ -777,12 +783,12 @@ void IT8951Display::update() { } void IT8951Display::update_mode(UpdateMode mode) { - if (!this->is_ready()) - return; if (mode == UPDATE_MODE_NONE) { - ESP_LOGW(TAG, "Unknown update mode"); + this->update(); return; } + if (!this->is_ready()) + return; this->start_update_(mode); } diff --git a/esphome/components/it8951/it8951.h b/esphome/components/it8951/it8951.h index a5ed03e8c4..e1273587c4 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" @@ -151,6 +150,7 @@ class IT8951Display : public Display, void loop() override; void dump_config() override; void on_safe_shutdown() override; + void on_powerdown() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } // --- Config setters (called from generated code) --- @@ -350,24 +350,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/jsn_sr04t/__init__.py b/esphome/components/jsn_sr04t/__init__.py index ef6335f316..672487d32a 100644 --- a/esphome/components/jsn_sr04t/__init__.py +++ b/esphome/components/jsn_sr04t/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Mafus1"] +DOMAIN = "jsn_sr04t" diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.cpp b/esphome/components/jsn_sr04t/jsn_sr04t.cpp index c67771a0b6..0c7dd27738 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.cpp +++ b/esphome/components/jsn_sr04t/jsn_sr04t.cpp @@ -6,7 +6,7 @@ namespace esphome::jsn_sr04t { -static const char *const TAG = "jsn_sr04t.sensor"; +ESPHOME_LOG_TAG(TAG, "jsn_sr04t.sensor"); void Jsnsr04tComponent::update() { this->write_byte((this->model_ == AJ_SR04M) ? 0x01 : 0x55); diff --git a/esphome/components/json/__init__.py b/esphome/components/json/__init__.py index af7eb7e733..cac06a29d2 100644 --- a/esphome/components/json/__init__.py +++ b/esphome/components/json/__init__.py @@ -4,6 +4,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "json" json_ns = cg.esphome_ns.namespace("json") CONFIG_SCHEMA = cv.All( @@ -21,3 +22,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 1b1eefe59b..28ba34d6ca 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -1,11 +1,13 @@ #include "json_util.h" + +#include #include "esphome/core/log.h" // ArduinoJson::Allocator is included via ArduinoJson.h in json_util.h namespace esphome::json { -static const char *const TAG = "json"; +ESPHOME_LOG_TAG(TAG, "json"); #ifdef USE_PSRAM // Global allocator that outlives all JsonDocuments returned by parse_json() @@ -44,11 +46,7 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { ESP_LOGE(TAG, "No data to parse"); return JsonObject(); // return unbound object } -#ifdef USE_PSRAM - JsonDocument json_document(&global_json_allocator); -#else - JsonDocument json_document; -#endif + JsonDocument json_document(heap_json_allocator()); if (json_document.overflowed()) { ESP_LOGE(TAG, "Could not allocate memory for JSON document!"); return JsonObject(); // return unbound object @@ -66,7 +64,34 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } -JsonBuilder::JsonBuilder() = default; +JsonBuilder::JsonBuilder() : doc_(heap_json_allocator()) {} +#ifdef USE_JSON_ARENA +JsonBuilder::JsonBuilder(ArduinoJson::Allocator *allocator) : doc_(allocator) {} +#endif + +ArduinoJson::Allocator *heap_json_allocator() { +#ifdef USE_PSRAM + return &global_json_allocator; +#else + return ArduinoJson::detail::DefaultAllocator::instance(); +#endif +} + +size_t JsonBuilder::serialize_to(char *buf, size_t cap) { + if (doc_.overflowed()) { + ESP_LOGE(TAG, "JSON document overflow"); + // Same contract as serializeJson; written by hand so no "{}" literal lives in RAM on ESP8266 + size_t n = 0; + if (n < cap) + buf[n++] = '{'; + if (n < cap) + buf[n++] = '}'; + if (n < cap) + buf[n] = '\0'; + return n; + } + return serializeJson(doc_, buf, cap); +} SerializationBuffer<> JsonBuilder::serialize() { // =========================================================================================== @@ -109,17 +134,7 @@ SerializationBuffer<> JsonBuilder::serialize() { constexpr size_t buf_size = SerializationBuffer<>::BUFFER_SIZE; SerializationBuffer<> result(buf_size - 1); // Max content size (reserve 1 for null) - if (doc_.overflowed()) { - ESP_LOGE(TAG, "JSON document overflow"); - auto *buf = result.data_writable_(); - buf[0] = '{'; - buf[1] = '}'; - buf[2] = '\0'; - result.set_size_(2); - return result; - } - - size_t size = serializeJson(doc_, result.data_writable_(), buf_size); + size_t size = this->serialize_to(result.data_writable_(), buf_size); if (size < buf_size) { // Fits in stack buffer - update size to actual length result.set_size_(size); @@ -134,7 +149,7 @@ SerializationBuffer<> JsonBuilder::serialize() { size_t heap_size = buf_size * 2; while (heap_size <= max_heap_size) { result.reallocate_heap_(heap_size - 1); - size = serializeJson(doc_, result.data_writable_(), heap_size); + size = this->serialize_to(result.data_writable_(), heap_size); if (size < heap_size) { result.set_size_(size); return result; diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 130e150332..9b4cc53a2e 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -1,11 +1,14 @@ #pragma once +#include +#include #include #include #include #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #define ARDUINOJSON_ENABLE_STD_STRING 1 // NOLINT @@ -15,6 +18,17 @@ namespace esphome::json { +// Link a string into the document instead of copying it. Only for a literal, a codegen table +// entry or a name set once during setup; never a stack buffer or an entity state. +inline JsonString linked(const char *s) { return JsonString(s, true); } +inline JsonString linked(const char *s, size_t len) { return JsonString(s, len, true); } +inline JsonString linked(const std::string &s) { return JsonString(s.c_str(), s.size(), true); } +JsonString linked(std::string &&) = delete; // a temporary dies before the document is serialized +#ifdef USE_ESP8266 +// A PROGMEM string is copied out of flash +inline ProgmemStr linked(ProgmemStr s) { return s; } +#endif + /// Buffer for JSON serialization that uses stack allocation for small payloads. /// Template parameter STACK_SIZE specifies the stack buffer size (default 512 bytes). /// Supports move semantics for efficient return-by-value. @@ -165,11 +179,88 @@ inline JsonDocument parse_json(const std::string &data) { return parse_json(reinterpret_cast(data.c_str()), data.size()); } +/// The allocator a JsonBuilder uses by default (PSRAM first when available) +ArduinoJson::Allocator *heap_json_allocator(); + +#ifdef USE_JSON_ARENA +/// Size of one ArduinoJson slot pool, the first allocation every document makes (1 KB on 32 bit targets) +constexpr size_t JSON_POOL_BYTES = ARDUINOJSON_POOL_CAPACITY * sizeof(ArduinoJson::detail::VariantData); +/// One pool plus room for copied string nodes; a 40 option select with linked options fits +constexpr size_t JSON_ARENA_SIZE = JSON_POOL_BYTES + 1152; +static_assert(sizeof(void *) != 4 || JSON_ARENA_SIZE == 2176, "the arena was sized for a 1 KB pool"); + +/// Bump allocator over a fixed buffer for a document built and serialized in one scope. What the +/// buffer cannot hold goes to the heap allocator; nothing is freed until the arena goes away. +template class JsonArena final : public ArduinoJson::Allocator { + public: + // Takes what the buffer cannot hold + explicit JsonArena(ArduinoJson::Allocator *fallback = heap_json_allocator()) : fallback_(fallback) {} + // The document points into buf_ + JsonArena(const JsonArena &) = delete; + JsonArena &operator=(const JsonArena &) = delete; + + void *allocate(size_t size) override { + if (size > N) { + return this->fallback_->allocate(size); // also keeps the rounding below from wrapping + } + size = (size + ALIGN - 1) & ~(ALIGN - 1); + if (size > N - this->used_) { + return this->fallback_->allocate(size); + } + this->last_ = this->used_; + this->used_ += size; + return this->buf_ + this->last_; + } + void deallocate(void *ptr) override { + if (!this->owns_(ptr)) { + this->fallback_->deallocate(ptr); + } + } + void *reallocate(void *ptr, size_t new_size) override { + if (!this->owns_(ptr)) { + return this->fallback_->reallocate(ptr, new_size); + } + const size_t off = static_cast(ptr) - this->buf_; + const size_t size = new_size > N ? N + ALIGN : (new_size + ALIGN - 1) & ~(ALIGN - 1); + const bool newest = off == this->last_; + if (newest && size <= N - off) { + this->used_ = off + size; // the newest block grows or shrinks in place + return ptr; + } + // An older block's size is unknown; copying to the end of the buffer stays in bounds + const size_t old_size = newest ? this->used_ - off : N - off; + void *moved = this->fallback_->allocate(new_size); + if (moved == nullptr) { + return nullptr; // the caller keeps ptr, so its arena space stays reserved + } + std::memcpy(moved, ptr, std::min(new_size, old_size)); + if (newest) { + this->used_ = off; // it moved to the heap, so its arena space is free again + } + return moved; + } + /// Bytes of the buffer handed out so far + size_t used() const { return this->used_; } + + private: + static constexpr size_t ALIGN = alignof(std::max_align_t); + bool owns_(const void *ptr) const { return ptr >= this->buf_ && ptr < this->buf_ + N; } + ArduinoJson::Allocator *fallback_; + alignas(ALIGN) uint8_t buf_[N]; + size_t used_{0}; + size_t last_{0}; +}; +#endif // USE_JSON_ARENA + /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: // Out of line: inlining the JsonDocument constructor duplicates it at every call site JsonBuilder(); +#ifdef USE_JSON_ARENA + // The builder must not outlive the allocator + explicit JsonBuilder(ArduinoJson::Allocator *allocator); +#endif JsonObject root() { if (!root_created_) { @@ -179,17 +270,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/kalman_combinator/__init__.py b/esphome/components/kalman_combinator/__init__.py index e69de29bb2..35782ff625 100644 --- a/esphome/components/kalman_combinator/__init__.py +++ b/esphome/components/kalman_combinator/__init__.py @@ -0,0 +1 @@ +DOMAIN = "kalman_combinator" diff --git a/esphome/components/kamstrup_kmp/__init__.py b/esphome/components/kamstrup_kmp/__init__.py index e69de29bb2..2446761ce9 100644 --- a/esphome/components/kamstrup_kmp/__init__.py +++ b/esphome/components/kamstrup_kmp/__init__.py @@ -0,0 +1 @@ +DOMAIN = "kamstrup_kmp" diff --git a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp index 24e5d25921..9713c72b61 100644 --- a/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +++ b/esphome/components/kamstrup_kmp/kamstrup_kmp.cpp @@ -5,7 +5,7 @@ namespace esphome::kamstrup_kmp { -static const char *const TAG = "kamstrup_kmp"; +ESPHOME_LOG_TAG(TAG, "kamstrup_kmp"); void KamstrupKMPComponent::dump_config() { ESP_LOGCONFIG(TAG, "kamstrup_kmp:"); diff --git a/esphome/components/key_collector/__init__.py b/esphome/components/key_collector/__init__.py index bf47b6df88..8dafb5da07 100644 --- a/esphome/components/key_collector/__init__.py +++ b/esphome/components/key_collector/__init__.py @@ -15,11 +15,11 @@ 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 CODEOWNERS = ["@ssieb"] +DOMAIN = "key_collector" MULTI_CONF = True @@ -36,8 +36,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 +133,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.cpp b/esphome/components/key_collector/key_collector.cpp index 42f02d39d4..bc201a1514 100644 --- a/esphome/components/key_collector/key_collector.cpp +++ b/esphome/components/key_collector/key_collector.cpp @@ -4,7 +4,7 @@ namespace esphome::key_collector { -static const char *const TAG = "key_collector"; +ESPHOME_LOG_TAG(TAG, "key_collector"); void KeyCollector::loop() { if ((this->timeout_ == 0) || this->result_.empty() || (millis() - this->last_key_time_ < this->timeout_)) 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/key_provider/__init__.py b/esphome/components/key_provider/__init__.py index f397382ff2..3008545f40 100644 --- a/esphome/components/key_provider/__init__.py +++ b/esphome/components/key_provider/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@ssieb"] +DOMAIN = "key_provider" key_provider_ns = cg.esphome_ns.namespace("key_provider") KeyProvider = key_provider_ns.class_("KeyProvider") diff --git a/esphome/components/kmeteriso/__init__.py b/esphome/components/kmeteriso/__init__.py index e69de29bb2..3316358096 100644 --- a/esphome/components/kmeteriso/__init__.py +++ b/esphome/components/kmeteriso/__init__.py @@ -0,0 +1 @@ +DOMAIN = "kmeteriso" diff --git a/esphome/components/kmeteriso/kmeteriso.cpp b/esphome/components/kmeteriso/kmeteriso.cpp index d6934a97ac..8bd8b47e21 100644 --- a/esphome/components/kmeteriso/kmeteriso.cpp +++ b/esphome/components/kmeteriso/kmeteriso.cpp @@ -5,7 +5,7 @@ namespace esphome::kmeteriso { -static const char *const TAG = "kmeteriso.sensor"; +ESPHOME_LOG_TAG(TAG, "kmeteriso.sensor"); static const uint8_t KMETER_ERROR_STATUS_REG = 0x20; static const uint8_t KMETER_TEMP_VAL_REG = 0x00; 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/__init__.py b/esphome/components/kuntze/__init__.py index e69de29bb2..34a9637a09 100644 --- a/esphome/components/kuntze/__init__.py +++ b/esphome/components/kuntze/__init__.py @@ -0,0 +1 @@ +DOMAIN = "kuntze" diff --git a/esphome/components/kuntze/kuntze.cpp b/esphome/components/kuntze/kuntze.cpp index cb04afe437..647227524e 100644 --- a/esphome/components/kuntze/kuntze.cpp +++ b/esphome/components/kuntze/kuntze.cpp @@ -3,7 +3,7 @@ namespace esphome::kuntze { -static const char *const TAG = "kuntze"; +ESPHOME_LOG_TAG(TAG, "kuntze"); static constexpr uint16_t REGISTER_PH = 4136; static constexpr uint16_t REGISTER_TEMPERATURE = 4160; 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/__init__.py b/esphome/components/lc709203f/__init__.py index 3be68d174f..3076e38527 100644 --- a/esphome/components/lc709203f/__init__.py +++ b/esphome/components/lc709203f/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ilikecake"] +DOMAIN = "lc709203f" diff --git a/esphome/components/lc709203f/lc709203f.cpp b/esphome/components/lc709203f/lc709203f.cpp index 36e5bce8e3..a97f5217a8 100644 --- a/esphome/components/lc709203f/lc709203f.cpp +++ b/esphome/components/lc709203f/lc709203f.cpp @@ -4,7 +4,7 @@ namespace esphome::lc709203f { -static const char *const TAG = "lc709203f.sensor"; +ESPHOME_LOG_TAG(TAG, "lc709203f.sensor"); // Device I2C address. This address is fixed. static const uint8_t LC709203F_I2C_ADDR_DEFAULT = 0x0B; 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/lcd_base/__init__.py b/esphome/components/lcd_base/__init__.py index 08ec395720..58efbd4884 100644 --- a/esphome/components/lcd_base/__init__.py +++ b/esphome/components/lcd_base/__init__.py @@ -7,6 +7,8 @@ from esphome.const import CONF_DATA, CONF_DIMENSIONS, CONF_POSITION from esphome.cpp_generator import MockObj from esphome.types import ConfigType +DOMAIN = "lcd_base" + CONF_USER_CHARACTERS = "user_characters" lcd_base_ns = cg.esphome_ns.namespace("lcd_base") diff --git a/esphome/components/lcd_base/lcd_display.cpp b/esphome/components/lcd_base/lcd_display.cpp index 0890da85c1..101e858bef 100644 --- a/esphome/components/lcd_base/lcd_display.cpp +++ b/esphome/components/lcd_base/lcd_display.cpp @@ -5,7 +5,7 @@ namespace esphome::lcd_base { -static const char *const TAG = "lcd"; +ESPHOME_LOG_TAG(TAG, "lcd"); // First set bit determines command, bits after that are the data. static const uint8_t LCD_DISPLAY_COMMAND_CLEAR_DISPLAY = 0x01; diff --git a/esphome/components/lcd_gpio/__init__.py b/esphome/components/lcd_gpio/__init__.py index e69de29bb2..9df8039c3b 100644 --- a/esphome/components/lcd_gpio/__init__.py +++ b/esphome/components/lcd_gpio/__init__.py @@ -0,0 +1 @@ +DOMAIN = "lcd_gpio" diff --git a/esphome/components/lcd_gpio/gpio_lcd_display.cpp b/esphome/components/lcd_gpio/gpio_lcd_display.cpp index 213fc8637e..92ae37eab8 100644 --- a/esphome/components/lcd_gpio/gpio_lcd_display.cpp +++ b/esphome/components/lcd_gpio/gpio_lcd_display.cpp @@ -3,7 +3,7 @@ namespace esphome::lcd_gpio { -static const char *const TAG = "lcd_gpio"; +ESPHOME_LOG_TAG(TAG, "lcd_gpio"); void GPIOLCDDisplay::setup() { this->rs_pin_->setup(); // OUTPUT diff --git a/esphome/components/lcd_menu/__init__.py b/esphome/components/lcd_menu/__init__.py index 88b8ac21d4..483a36491d 100644 --- a/esphome/components/lcd_menu/__init__.py +++ b/esphome/components/lcd_menu/__init__.py @@ -11,6 +11,7 @@ from esphome.core.entity_helpers import inherit_property_from from esphome.types import ConfigType CODEOWNERS = ["@numo68"] +DOMAIN = "lcd_menu" AUTO_LOAD = ["display_menu_base"] diff --git a/esphome/components/lcd_menu/lcd_menu.cpp b/esphome/components/lcd_menu/lcd_menu.cpp index f731817bdb..7ced6e67ce 100644 --- a/esphome/components/lcd_menu/lcd_menu.cpp +++ b/esphome/components/lcd_menu/lcd_menu.cpp @@ -4,7 +4,7 @@ namespace esphome::lcd_menu { -static const char *const TAG = "lcd_menu"; +ESPHOME_LOG_TAG(TAG, "lcd_menu"); void LCDCharacterMenuComponent::setup() { if (this->display_->is_failed()) { diff --git a/esphome/components/lcd_pcf8574/__init__.py b/esphome/components/lcd_pcf8574/__init__.py index e69de29bb2..cfeba4c863 100644 --- a/esphome/components/lcd_pcf8574/__init__.py +++ b/esphome/components/lcd_pcf8574/__init__.py @@ -0,0 +1 @@ +DOMAIN = "lcd_pcf8574" diff --git a/esphome/components/lcd_pcf8574/pcf8574_display.cpp b/esphome/components/lcd_pcf8574/pcf8574_display.cpp index d5fc683598..fc7f5ff2f5 100644 --- a/esphome/components/lcd_pcf8574/pcf8574_display.cpp +++ b/esphome/components/lcd_pcf8574/pcf8574_display.cpp @@ -4,7 +4,7 @@ namespace esphome::lcd_pcf8574 { -static const char *const TAG = "lcd_pcf8574"; +ESPHOME_LOG_TAG(TAG, "lcd_pcf8574"); static const uint8_t LCD_DISPLAY_BACKLIGHT_ON = 0x08; static const uint8_t LCD_DISPLAY_BACKLIGHT_OFF = 0x00; diff --git a/esphome/components/ld2410/__init__.py b/esphome/components/ld2410/__init__.py index 19786f38d3..e2728aa73c 100644 --- a/esphome/components/ld2410/__init__.py +++ b/esphome/components/ld2410/__init__.py @@ -4,13 +4,12 @@ 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"] DEPENDENCIES = ["uart"] CODEOWNERS = ["@sebcaps", "@regevbr"] +DOMAIN = "ld2410" MULTI_CONF = True ld2410_ns = cg.esphome_ns.namespace("ld2410") @@ -86,33 +85,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.cpp b/esphome/components/ld2410/ld2410.cpp index 914de8e145..22bd20d1e5 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -12,7 +12,7 @@ namespace esphome::ld2410 { -static const char *const TAG = "ld2410"; +ESPHOME_LOG_TAG(TAG, "ld2410"); enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, 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..a8636f3df6 100644 --- a/esphome/components/ld2410/sensor.py +++ b/esphome/components/ld2410/sensor.py @@ -34,83 +34,35 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2410_ID): cv.use_id(LD2410Component), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_LIGHTBULB, ), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), @@ -123,29 +75,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), @@ -157,30 +93,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/ld2410s/__init__.py b/esphome/components/ld2410s/__init__.py new file mode 100644 index 0000000000..1c39c7eab3 --- /dev/null +++ b/esphome/components/ld2410s/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@NovakIrs"] +DEPENDENCIES = ["uart"] +DOMAIN = "ld2410s" +MULTI_CONF = True + +CONF_LD2410S_ID = "ld2410s_id" + +ld2410s_ns = cg.esphome_ns.namespace(DOMAIN) +LD2410SComponent = ld2410s_ns.class_("LD2410SComponent", cg.Component, uart.UARTDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2410SComponent), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + baud_rate=115200, + parity="NONE", + stop_bits=1, +) + + +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/ld2410s/binary_sensor.py b/esphome/components/ld2410s/binary_sensor.py new file mode 100644 index 0000000000..83e0a36cba --- /dev/null +++ b/esphome/components/ld2410s/binary_sensor.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_HAS_TARGET, DEVICE_CLASS_OCCUPANCY, ICON_ACCOUNT +from esphome.types import ConfigType + +from . import CONF_LD2410S_ID, LD2410SComponent + +DEPENDENCIES = ["ld2410s"] + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent), + cv.Optional(CONF_HAS_TARGET): binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_OCCUPANCY, + icon=ICON_ACCOUNT, + ), +} + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2410S_ID]) + binary_sensors = binary_sensor.sub_binary_sensors(config) + await binary_sensors(CONF_HAS_TARGET, hub.set_target_binary_sensor) diff --git a/esphome/components/ld2410s/ld2410s.cpp b/esphome/components/ld2410s/ld2410s.cpp new file mode 100644 index 0000000000..6462236ac9 --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.cpp @@ -0,0 +1,314 @@ +#include "ld2410s.h" +#include +#include +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ld2410s { + +ESPHOME_LOG_TAG(TAG, "ld2410s"); + +static constexpr uint8_t SHORT_DATA_FRAME_HEADER = 0x6E; +static constexpr uint8_t SHORT_DATA_FRAME_FOOTER[] PROGMEM = {0x62}; +static constexpr uint8_t SHORT_DATA_FRAME_SIZE = 5; // header, state, distance low, distance high, footer +static constexpr uint8_t STD_DATA_FRAME_HEADER[] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t STD_DATA_FRAME_FOOTER[] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; +static constexpr uint8_t CMD_FRAME_HEADER[] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t LONG_HEADER_SIZE = 4; +static constexpr uint8_t LENGTH_FIELD_SIZE = 2; +static constexpr uint8_t LONG_PAYLOAD_POS = LONG_HEADER_SIZE + LENGTH_FIELD_SIZE; + +static constexpr uint16_t CMD_CONFIRMATION = 0x0100; // set in the command word of an acknowledgement +static constexpr uint16_t CONFIG_MODE_START_CMD = 0x00FF; +static constexpr uint16_t CONFIG_MODE_START_VALUE = 0x0001; +static constexpr uint16_t CONFIG_MODE_END_CMD = 0x00FE; +static constexpr uint16_t OUTPUT_MODE_SWITCH_CMD = 0x007A; +// Minimal output mode (short frames carrying presence and distance only) is selected by six zero bytes +static constexpr uint8_t OUTPUT_MODE_MINIMAL_SIZE = 6; +static constexpr uint8_t CMD_FRAME_MAX_SIZE = + LONG_PAYLOAD_POS + 2 + OUTPUT_MODE_MINIMAL_SIZE + sizeof(CMD_FRAME_FOOTER); + +// Everything the module needs at start: enter configuration mode, pick the output mode, leave it +static constexpr uint16_t INIT_SEQUENCE[] PROGMEM = {CONFIG_MODE_START_CMD, OUTPUT_MODE_SWITCH_CMD, + CONFIG_MODE_END_CMD}; +static constexpr uint8_t INIT_SEQUENCE_LENGTH = sizeof(INIT_SEQUENCE) / sizeof(INIT_SEQUENCE[0]); +static uint16_t init_command(uint8_t step) { return progmem_read_uint16(&INIT_SEQUENCE[step]); } +static constexpr uint8_t INIT_MAX_TIMEOUTS = 2; +static constexpr uint32_t ACK_TIMEOUT_MS = 300; +static constexpr uint32_t COMMAND_GAP_MS = 300; // pause after an acknowledgement before the next command +static constexpr uint32_t REINIT_PAUSE_MS = 15000; // pause before starting over after the module stopped answering +static constexpr uint32_t LINK_TIMEOUT_MS = 5000; // the module streams frames continuously; silence means it is gone + +static constexpr uint8_t DATA_TYPE_STANDARD = 0x01; + +void LD2410SComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LD2410S:"); +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Has Target", this->target_binary_sensor_); +#endif +} + +void LD2410SComponent::loop() { + uint8_t chunk[16]; + // Take the count once so one pass handles a bounded number of bytes + size_t avail = this->available(); + while (avail > 0) { + const size_t len = std::min(avail, sizeof(chunk)); + if (!this->read_array(chunk, len)) { + ESP_LOGV(TAG, "UART read of %u bytes failed", static_cast(len)); + break; + } + for (size_t i = 0; i < len; i++) { + this->receive_byte_(chunk[i]); + } + avail -= len; + } + const uint32_t now = App.get_loop_component_start_time(); + this->run_init_sequence_(now); + if (this->init_step_ >= INIT_SEQUENCE_LENGTH && now - this->last_frame_at_ > LINK_TIMEOUT_MS) { + // The count tells real silence apart from frames that never parse (wrong baud rate or firmware) + ESP_LOGW(TAG, "No data for %" PRIu32 " s (%u frames dropped), starting over", LINK_TIMEOUT_MS / 1000, + this->dropped_frames_); + this->dropped_frames_ = 0; + this->status_set_warning(); + this->init_step_ = 0; + this->init_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now; + } +} + +void LD2410SComponent::run_init_sequence_(uint32_t now) { + if (this->init_step_ >= INIT_SEQUENCE_LENGTH || static_cast(now - this->next_send_at_) < 0) { + return; + } + const uint16_t command = init_command(this->init_step_); + if (this->awaiting_ack_) { + if (++this->init_timeouts_ > INIT_MAX_TIMEOUTS) { + ESP_LOGW(TAG, "No acknowledgement for command %04X, starting over in %" PRIu32 " s", command, + REINIT_PAUSE_MS / 1000); + // Leave configuration mode whatever state the module is in, then begin again + this->send_command_(CONFIG_MODE_END_CMD); + this->init_step_ = 0; + this->init_timeouts_ = 0; + this->awaiting_ack_ = false; + this->next_send_at_ = now + REINIT_PAUSE_MS; + return; + } + ESP_LOGD(TAG, "No acknowledgement for command %04X, resending", command); + } + this->send_command_(command); + this->awaiting_ack_ = true; + this->next_send_at_ = now + ACK_TIMEOUT_MS; +} + +void LD2410SComponent::send_command_(uint16_t command) { + uint8_t frame[CMD_FRAME_MAX_SIZE]; + progmem_memcpy(frame, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + uint8_t pos = LONG_PAYLOAD_POS; + frame[pos++] = command & 0xFF; + frame[pos++] = command >> 8; + switch (command) { + case OUTPUT_MODE_SWITCH_CMD: + memset(&frame[pos], 0, OUTPUT_MODE_MINIMAL_SIZE); + pos += OUTPUT_MODE_MINIMAL_SIZE; + break; + case CONFIG_MODE_START_CMD: + frame[pos++] = CONFIG_MODE_START_VALUE & 0xFF; + frame[pos++] = CONFIG_MODE_START_VALUE >> 8; + break; + default: + break; + } + frame[LONG_HEADER_SIZE] = pos - LONG_PAYLOAD_POS; // payload length, little-endian + frame[LONG_HEADER_SIZE + 1] = 0; + progmem_memcpy(&frame[pos], CMD_FRAME_FOOTER, sizeof(CMD_FRAME_FOOTER)); + pos += sizeof(CMD_FRAME_FOOTER); + this->write_array(frame, pos); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char hex_buf[format_hex_pretty_size(CMD_FRAME_MAX_SIZE)]; + ESP_LOGVV(TAG, "> %s", format_hex_pretty_to(hex_buf, frame, pos, ' ')); +#endif +} + +void LD2410SComponent::receive_byte_(uint8_t byte) { + if (this->rx_len_ >= RX_BUFFER_SIZE) { + this->reset_frame_(); + } + this->rx_buffer_[this->rx_len_++] = byte; + + if (this->frame_type_ == FrameType::NONE) { + if (this->rx_len_ == 1 && byte == SHORT_DATA_FRAME_HEADER) { + this->frame_type_ = FrameType::SHORT_DATA; + this->expected_len_ = SHORT_DATA_FRAME_SIZE; + return; + } + // The two long headers are told apart once all four bytes are in + uint8_t std_header[LONG_HEADER_SIZE]; + uint8_t cmd_header[LONG_HEADER_SIZE]; + progmem_memcpy(std_header, STD_DATA_FRAME_HEADER, LONG_HEADER_SIZE); + progmem_memcpy(cmd_header, CMD_FRAME_HEADER, LONG_HEADER_SIZE); + const bool std_prefix = memcmp(this->rx_buffer_, std_header, this->rx_len_) == 0; + const bool cmd_prefix = memcmp(this->rx_buffer_, cmd_header, this->rx_len_) == 0; + if (std_prefix || cmd_prefix) { + if (this->rx_len_ == LONG_HEADER_SIZE) { + this->frame_type_ = std_prefix ? FrameType::STD_DATA : FrameType::COMMAND; + } + return; + } + // Not a header: drop what was collected, and try this byte as the start of the next frame + const bool retry = this->rx_len_ > 1; + this->reset_frame_(); + if (retry) { + this->receive_byte_(byte); + } + return; + } + + if (this->expected_len_ == 0) { + if (this->rx_len_ < LONG_PAYLOAD_POS) { + return; + } + // Standard data and command frames carry a little-endian payload length after the header + const uint16_t payload_len = + encode_uint16(this->rx_buffer_[LONG_HEADER_SIZE + 1], this->rx_buffer_[LONG_HEADER_SIZE]); + if (payload_len > RX_BUFFER_SIZE - LONG_PAYLOAD_POS - sizeof(CMD_FRAME_FOOTER)) { + ESP_LOGV(TAG, "Dropping frame with a %u byte payload, larger than the receive buffer", payload_len); + this->count_dropped_frame_(); + this->reset_frame_(); + return; + } + this->expected_len_ = LONG_PAYLOAD_POS + payload_len + sizeof(CMD_FRAME_FOOTER); + return; + } + + if (this->rx_len_ < this->expected_len_) { + return; + } + if (this->handle_frame_() || this->rescanning_) { + this->reset_frame_(); + return; + } + // A truncated long frame swallowed the bytes that followed it; try them again as new frames, once + uint8_t rest[RX_BUFFER_SIZE]; + const uint8_t rest_len = this->rx_len_ - 1; + memcpy(rest, &this->rx_buffer_[1], rest_len); + this->reset_frame_(); + this->rescanning_ = true; + for (uint8_t i = 0; i < rest_len; i++) { + this->receive_byte_(rest[i]); + } + this->rescanning_ = false; +} + +bool LD2410SComponent::handle_frame_() { + const uint8_t *footer_src; + uint8_t footer_size = sizeof(CMD_FRAME_FOOTER); + uint8_t payload_pos = LONG_PAYLOAD_POS; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + footer_src = SHORT_DATA_FRAME_FOOTER; + footer_size = sizeof(SHORT_DATA_FRAME_FOOTER); + payload_pos = 1; + break; + case FrameType::STD_DATA: + footer_src = STD_DATA_FRAME_FOOTER; + break; + default: + footer_src = CMD_FRAME_FOOTER; + break; + } + uint8_t footer[sizeof(CMD_FRAME_FOOTER)]; + progmem_memcpy(footer, footer_src, footer_size); + if (memcmp(&this->rx_buffer_[this->rx_len_ - footer_size], footer, footer_size) != 0) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(RX_BUFFER_SIZE)]; + ESP_LOGV(TAG, "Footer does not match header: %s", + format_hex_pretty_to(hex_buf, this->rx_buffer_, this->rx_len_, ' ')); +#endif + this->count_dropped_frame_(); + return false; + } + const uint8_t *payload = &this->rx_buffer_[payload_pos]; + const uint16_t payload_len = this->rx_len_ - payload_pos - footer_size; + switch (this->frame_type_) { + case FrameType::SHORT_DATA: + case FrameType::STD_DATA: + if (!this->handle_data_frame_(payload, payload_len)) { + this->count_dropped_frame_(); + break; + } + this->last_frame_at_ = App.get_loop_component_start_time(); + this->dropped_frames_ = 0; + break; + default: + this->handle_command_ack_(payload, payload_len); + break; + } + return true; +} + +bool LD2410SComponent::handle_data_frame_(const uint8_t *payload, uint16_t len) { + uint8_t state; + if (this->frame_type_ == FrameType::SHORT_DATA) { + // [state][distance low][distance high] + state = payload[0]; + } else if (len >= 4 && payload[0] == DATA_TYPE_STANDARD) { + // [type][state][distance low][distance high]...; the module only sends these before the init + // sequence switches it to minimal output + state = payload[1]; + } else { + if (len >= 1) { + ESP_LOGV(TAG, "Ignoring data frame type %02X", payload[0]); + } + return true; + } + // States 0 and 1 mean no target, 2 and 3 a target; anything else is line noise that happened to + // carry the header and footer bytes + if (state > 3) { + ESP_LOGV(TAG, "Dropping frame with impossible state %u", state); + return false; + } + this->publish_target_(state > 1); + return true; +} + +void LD2410SComponent::handle_command_ack_(const uint8_t *payload, uint16_t len) { + if (len < 4) { + ESP_LOGV(TAG, "Acknowledgement too short (%u bytes)", len); + return; + } + const uint16_t command_word = encode_uint16(payload[1], payload[0]); + const uint16_t ack = encode_uint16(payload[3], payload[2]); + if (!this->awaiting_ack_ || command_word != (init_command(this->init_step_) | CMD_CONFIRMATION)) { + ESP_LOGV(TAG, "Unexpected acknowledgement %04X", command_word); + return; + } + if (ack != 0) { + // Leave the command pending so the timeout resends it + ESP_LOGW(TAG, "Module rejected command %04X, ack %04X", init_command(this->init_step_), ack); + return; + } + this->awaiting_ack_ = false; + this->init_timeouts_ = 0; + this->init_step_++; + this->next_send_at_ = App.get_loop_component_start_time() + COMMAND_GAP_MS; + if (this->init_step_ >= INIT_SEQUENCE_LENGTH) { + ESP_LOGD(TAG, "Setup done"); + this->last_frame_at_ = App.get_loop_component_start_time(); + this->status_clear_warning(); + } +} + +void LD2410SComponent::publish_target_(bool target) { +#ifdef USE_BINARY_SENSOR + if (this->target_binary_sensor_ != nullptr) { + this->target_binary_sensor_->publish_state(target); + } +#endif +} + +} // namespace esphome::ld2410s diff --git a/esphome/components/ld2410s/ld2410s.h b/esphome/components/ld2410s/ld2410s.h new file mode 100644 index 0000000000..4af809836c --- /dev/null +++ b/esphome/components/ld2410s/ld2410s.h @@ -0,0 +1,62 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/defines.h" +#include "esphome/components/uart/uart.h" +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif + +namespace esphome::ld2410s { + +// Largest frame read here is the standard data frame: 4 header + 2 length + payload + 4 footer +static constexpr size_t RX_BUFFER_SIZE = 96; +static_assert(RX_BUFFER_SIZE <= 255, "frame lengths are kept in a byte"); + +class LD2410SComponent final : public Component, public uart::UARTDevice { +#ifdef USE_BINARY_SENSOR + SUB_BINARY_SENSOR(target) +#endif + + public: + void setup() override { this->status_set_warning(); } // cleared once the module acknowledged the init sequence + void loop() override; + void dump_config() override; + + protected: + enum class FrameType : uint8_t { NONE, SHORT_DATA, STD_DATA, COMMAND }; + + void receive_byte_(uint8_t byte); + /// Returns false when the footer does not match, so the bytes can be scanned again. + bool handle_frame_(); + /// Returns false for a data frame whose state byte is impossible, so it counts as dropped. + bool handle_data_frame_(const uint8_t *payload, uint16_t len); + void handle_command_ack_(const uint8_t *payload, uint16_t len); + void count_dropped_frame_() { + if (this->dropped_frames_ < UINT8_MAX) { + this->dropped_frames_++; + } + } + void reset_frame_() { + this->rx_len_ = 0; + this->expected_len_ = 0; + this->frame_type_ = FrameType::NONE; + } + void run_init_sequence_(uint32_t now); + void send_command_(uint16_t command); + void publish_target_(bool target); + + uint32_t next_send_at_{0}; + uint32_t last_frame_at_{0}; + uint8_t rx_buffer_[RX_BUFFER_SIZE]; + uint8_t rx_len_{0}; + uint8_t expected_len_{0}; + uint8_t init_step_{0}; + uint8_t init_timeouts_{0}; + uint8_t dropped_frames_{0}; // since the last accepted data frame, saturates at 255 + FrameType frame_type_{FrameType::NONE}; + bool awaiting_ack_{false}; + bool rescanning_{false}; +}; + +} // namespace esphome::ld2410s diff --git a/esphome/components/ld2412/__init__.py b/esphome/components/ld2412/__init__.py index 82db319861..fd63f78c3d 100644 --- a/esphome/components/ld2412/__init__.py +++ b/esphome/components/ld2412/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] CODEOWNERS = ["@Rihan9"] DEPENDENCIES = ["uart"] +DOMAIN = "ld2412" MULTI_CONF = True LD2412_ns = cg.esphome_ns.namespace("ld2412") 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/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 7041b7539f..c6137f29f7 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -10,9 +10,12 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include +#include + namespace esphome::ld2412 { -static const char *const TAG = "ld2412"; +ESPHOME_LOG_TAG(TAG, "ld2412"); enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, @@ -73,8 +76,10 @@ enum PeriodicDataValue : uint8_t { }; enum AckData : uint8_t { + FRAME_LENGTH = 4, // little-endian count of the bytes between it and the footer COMMAND = 6, COMMAND_STATUS = 7, + ACK_PAYLOAD = 10, }; // Memory-efficient lookup tables @@ -191,6 +196,9 @@ static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] = {0xF4, 0xF3, 0x static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] = {0xF8, 0xF7, 0xF6, 0xF5}; // MAC address the module uses when Bluetooth is disabled static constexpr uint8_t NO_MAC[] = {0x08, 0x05, 0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t GATE_SENS_ACK_SIZE = ACK_PAYLOAD + TOTAL_GATES + HEADER_FOOTER_SIZE; +// The length field counts the command word, the status word and the gates +static constexpr uint16_t GATE_SENS_ACK_LENGTH = GATE_SENS_ACK_SIZE - COMMAND - HEADER_FOOTER_SIZE; static inline bool validate_header_footer(const uint8_t *header_footer, const uint8_t *buffer) { return std::memcmp(header_footer, buffer, HEADER_FOOTER_SIZE) == 0; @@ -240,10 +248,10 @@ void LD2412Component::dump_config() { LOG_NUMBER(" ", "MaxDistanceGate", this->max_distance_gate_number_); LOG_NUMBER(" ", "MinDistanceGate", this->min_distance_gate_number_); LOG_NUMBER(" ", "Timeout", this->timeout_number_); - for (number::Number *n : this->gate_move_threshold_numbers_) { + for (number::Number *n : this->gate_move_thresholds_.numbers) { LOG_NUMBER(" ", "Move Thresholds", n); } - for (number::Number *n : this->gate_still_threshold_numbers_) { + for (number::Number *n : this->gate_still_thresholds_.numbers) { LOG_NUMBER(" ", "Still Thresholds", n); } #endif @@ -460,6 +468,13 @@ void set_number_value(number::Number *n, float value) { n->publish_state(value); } } + +// NaN marks a value the module never received +static inline bool has_value(number::Number *n) { return n != nullptr && n->has_state() && !std::isnan(n->state); } + +// Timeout ids 0 (still) and 1 (motion) for a write waiting on a re-query +static constexpr uint32_t GATE_QUERY_TIMEOUT_ID = 0; +static constexpr uint32_t GATE_QUERY_TIMEOUT_MS = 2000; #endif bool LD2412Component::handle_ack_data_() { @@ -595,19 +610,34 @@ bool LD2412Component::handle_ack_data_() { ESP_LOGV(TAG, "Handled set light control command"); break; - case CMD_QUERY_MOTION_GATE_SENS: { -#ifdef USE_NUMBER - for (size_t i = 0; i < this->gate_move_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { - set_number_value(this->gate_move_threshold_numbers_[i], this->buffer_data_[10 + i]); - } -#endif - break; - } - + case CMD_QUERY_MOTION_GATE_SENS: case CMD_QUERY_STATIC_GATE_SENS: { #ifdef USE_NUMBER - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size() && (10 + i) < this->buffer_pos_; i++) { - set_number_value(this->gate_still_threshold_numbers_[i], this->buffer_data_[10 + i]); + const uint16_t declared = encode_uint16(this->buffer_data_[FRAME_LENGTH + 1], this->buffer_data_[FRAME_LENGTH]); + if (declared != GATE_SENS_ACK_LENGTH || this->buffer_pos_ > GATE_SENS_ACK_SIZE) { + // A truncated answer merged with the next frame, or a length this driver does not know + ESP_LOGW(TAG, "Dropping gate threshold answer of %u bytes", this->buffer_pos_); + return true; + } + if (this->buffer_pos_ < GATE_SENS_ACK_SIZE) { + return false; // the footer pattern appeared inside the payload, keep reading + } + const bool motion = this->buffer_data_[COMMAND] == CMD_QUERY_MOTION_GATE_SENS; + GateThresholds &group = this->gate_thresholds_(motion); + // True only while a held-back write waits for this answer + const bool pending = this->cancel_timeout(GATE_QUERY_TIMEOUT_ID + motion); + for (size_t i = 0; i < TOTAL_GATES; i++) { + group.last_read[i] = this->buffer_data_[ACK_PAYLOAD + i]; + // The pending write's user values must survive the answer + if (!pending || !has_value(group.numbers[i])) { + set_number_value(group.numbers[i], group.last_read[i]); + } + } + group.read = true; + if (pending) { + this->set_config_mode_(true); + this->send_gate_thresholds_(motion); + this->set_config_mode_(false); } #endif break; @@ -747,6 +777,11 @@ void LD2412Component::set_engineering_mode(bool enable) { void LD2412Component::factory_reset() { this->set_config_mode_(true); this->send_command_(CMD_FACTORY_RESET, nullptr, 0); +#ifdef USE_NUMBER + // Reset discards the thresholds; the next write must wait for the re-read + this->gate_move_thresholds_.read = false; + this->gate_still_thresholds_.read = false; +#endif this->set_timeout(2000, [this]() { this->restart_and_read_all_info(); }); } @@ -805,24 +840,48 @@ void LD2412Component::set_basic_config() { } #ifdef USE_NUMBER -void LD2412Component::set_gate_threshold() { - if (this->gate_move_threshold_numbers_.empty() && this->gate_still_threshold_numbers_.empty()) { - return; // No gate threshold numbers set; nothing to do here +void LD2412Component::send_gate_thresholds_(bool motion) { + GateThresholds &group = this->gate_thresholds_(motion); + const uint8_t command = motion ? CMD_MOTION_GATE_SENS : CMD_STATIC_GATE_SENS; + uint8_t value[TOTAL_GATES]; + uint8_t configured = 0; + for (uint8_t i = 0; i < TOTAL_GATES; i++) { + number::Number *n = group.numbers[i]; + if (has_value(n)) { + value[i] = static_cast(n->state); + configured++; + } else { + value[i] = group.last_read[i]; + } } - uint8_t value[TOTAL_GATES] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + if (configured < TOTAL_GATES && !group.read) { + // Unread gates would go out as zero, which is maximum sensitivity + ESP_LOGW(TAG, "Command %02X held back until the module reports its gate thresholds", command); + this->send_command_(motion ? CMD_QUERY_MOTION_GATE_SENS : CMD_QUERY_STATIC_GATE_SENS, nullptr, 0); + this->set_timeout(GATE_QUERY_TIMEOUT_ID + motion, GATE_QUERY_TIMEOUT_MS, + [this, motion]() { this->gate_query_timeout_(motion); }); + return; + } + this->send_command_(command, value, sizeof(value)); +} + +void LD2412Component::gate_query_timeout_(bool motion) { + ESP_LOGE(TAG, "Command %02X not sent, the module did not report its gate thresholds", + motion ? CMD_MOTION_GATE_SENS : CMD_STATIC_GATE_SENS); + // The values on hand never reached the module or are stale, so forgetting them loses nothing + for (number::Number *n : this->gate_thresholds_(motion).numbers) { + if (n != nullptr) { + n->publish_state(NAN); + } + } +} + +void LD2412Component::set_gate_threshold(number::Number *n) { + // Found by pointer: 14 compares per write beat a flag per number + const auto &motion_numbers = this->gate_move_thresholds_.numbers; + const bool motion = std::find(motion_numbers.begin(), motion_numbers.end(), n) != motion_numbers.end(); this->set_config_mode_(true); - if (!this->gate_move_threshold_numbers_.empty()) { - for (size_t i = 0; i < this->gate_move_threshold_numbers_.size(); i++) { - value[i] = lowbyte(static_cast(this->gate_move_threshold_numbers_[i]->state)); - } - this->send_command_(CMD_MOTION_GATE_SENS, value, sizeof(value)); - } - if (!this->gate_still_threshold_numbers_.empty()) { - for (size_t i = 0; i < this->gate_still_threshold_numbers_.size(); i++) { - value[i] = lowbyte(static_cast(this->gate_still_threshold_numbers_[i]->state)); - } - this->send_command_(CMD_STATIC_GATE_SENS, value, sizeof(value)); - } + this->send_gate_thresholds_(motion); this->set_config_mode_(false); } diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index a52402c2ea..c427d024f3 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -85,11 +85,13 @@ class LD2412Component final : public Component, public uart::UARTDevice { void set_light_out_control(); void set_basic_config(); #ifdef USE_NUMBER - void set_gate_move_threshold_number(uint8_t gate, number::Number *n) { this->gate_move_threshold_numbers_[gate] = n; } - void set_gate_still_threshold_number(uint8_t gate, number::Number *n) { - this->gate_still_threshold_numbers_[gate] = n; + void set_gate_move_threshold_number(uint8_t gate, number::Number *n) { + this->gate_move_thresholds_.numbers[gate] = n; } - void set_gate_threshold(); + void set_gate_still_threshold_number(uint8_t gate, number::Number *n) { + this->gate_still_thresholds_.numbers[gate] = n; + } + void set_gate_threshold(number::Number *n); void get_gate_threshold(); #endif #ifdef USE_SENSOR @@ -118,6 +120,19 @@ class LD2412Component final : public Component, public uart::UARTDevice { void query_light_control_(); void restart_(); void query_dynamic_background_correction_(); +#ifdef USE_NUMBER + // One command carries all 14 gates, so unconfigured gates get the module's last value + struct GateThresholds { + std::array numbers{}; + std::array last_read{}; + bool read{false}; // answered since boot or the last factory reset + }; + GateThresholds &gate_thresholds_(bool motion) { + return motion ? this->gate_move_thresholds_ : this->gate_still_thresholds_; + } + void send_gate_thresholds_(bool motion); + void gate_query_timeout_(bool motion); +#endif uint8_t light_function_ = 0; uint8_t light_threshold_ = 0; @@ -129,8 +144,8 @@ class LD2412Component final : public Component, public uart::UARTDevice { bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; #ifdef USE_NUMBER - std::array gate_move_threshold_numbers_{}; - std::array gate_still_threshold_numbers_{}; + GateThresholds gate_move_thresholds_; + GateThresholds gate_still_thresholds_; #endif #ifdef USE_SENSOR std::array, TOTAL_GATES> gate_move_sensors_{}; diff --git a/esphome/components/ld2412/number/__init__.py b/esphome/components/ld2412/number/__init__.py index f27e241491..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]) - await number.register_number( - n, move_config, min_value=0, max_value=100, step=1 - ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_move_threshold_number(x, n)) + n = await number.new_number(move_config, min_value=0, max_value=100, step=1) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_move_threshold_number(x, n)) still_config = gate_conf[CONF_STILL_THRESHOLD] - n = cg.new_Pvariable(still_config[CONF_ID]) - await number.register_number( - n, still_config, min_value=0, max_value=100, step=1 + n = await number.new_number( + still_config, min_value=0, max_value=100, step=1 ) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_gate_still_threshold_number(x, n)) - if timeout_config := config.get(CONF_TIMEOUT): - n = await number.new_number(timeout_config, min_value=0, max_value=900, step=1) - await cg.register_parented(n, config[CONF_LD2412_ID]) - cg.add(LD2412_component.set_timeout_number(n)) + await cg.register_parented(n, hub) + cg.add(hub.set_gate_still_threshold_number(x, n)) + await numbers( + CONF_TIMEOUT, + hub.set_timeout_number, + min_value=0, + max_value=900, + step=1, + ) diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index a0a525a810..1af7b1e7f2 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2412 { void GateThresholdNumber::control(float value) { this->publish_state(value); - this->parent_->set_gate_threshold(); + this->parent_->set_gate_threshold(this); } } // namespace esphome::ld2412 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/sensor.py b/esphome/components/ld2412/sensor.py index 0b6e676931..f350fb7050 100644 --- a/esphome/components/ld2412/sensor.py +++ b/esphome/components/ld2412/sensor.py @@ -34,84 +34,36 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2412_ID): cv.use_id(LD2412Component), cv.Optional(CONF_DETECTION_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_LIGHT): sensor.sensor_schema( device_class=DEVICE_CLASS_ILLUMINANCE, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_LIGHTBULB, unit_of_measurement=UNIT_EMPTY, # No standard unit for this light sensor ), cv.Optional(CONF_MOVING_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_MOVING_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SIGNAL, unit_of_measurement=UNIT_CENTIMETER, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), @@ -124,29 +76,13 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_MOVE_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MOTION_SENSOR, unit_of_measurement=UNIT_PERCENT, ), cv.Optional(CONF_STILL_ENERGY): sensor.sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FLASH, unit_of_measurement=UNIT_PERCENT, ), @@ -158,30 +94,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/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/__init__.py b/esphome/components/ld2420/__init__.py index 5a5aabeba0..87a54d278a 100644 --- a/esphome/components/ld2420/__init__.py +++ b/esphome/components/ld2420/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@descipher"] +DOMAIN = "ld2420" DEPENDENCIES = ["uart"] 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/binary_sensor/ld2420_binary_sensor.cpp b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp index 6297cba2e7..87375ccbd9 100644 --- a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp +++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2420 { -static const char *const TAG = "ld2420.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "ld2420.binary_sensor"); void LD2420BinarySensor::dump_config() { ESP_LOGCONFIG(TAG, "Binary Sensor:"); 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/button/reconfig_buttons.cpp b/esphome/components/ld2420/button/reconfig_buttons.cpp index 1e748e59b8..85e3411f49 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.cpp +++ b/esphome/components/ld2420/button/reconfig_buttons.cpp @@ -2,7 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "ld2420.button"; +ESPHOME_LOG_TAG(TAG, "ld2420.button"); namespace esphome::ld2420 { diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index e342ead414..dec7e42de4 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -60,7 +60,7 @@ Gate 0 low thresh = 20 00 uint16_t 0x0020, Threshold value = 60 EA 00 00 uint32_ namespace esphome::ld2420 { -static const char *const TAG = "ld2420"; +ESPHOME_LOG_TAG(TAG, "ld2420"); // Local const's static constexpr uint16_t REFRESH_RATE_MS = 1000; 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/number/gate_config_number.cpp b/esphome/components/ld2420/number/gate_config_number.cpp index 998eed2188..491cd85734 100644 --- a/esphome/components/ld2420/number/gate_config_number.cpp +++ b/esphome/components/ld2420/number/gate_config_number.cpp @@ -2,7 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "ld2420.number"; +ESPHOME_LOG_TAG(TAG, "ld2420.number"); namespace esphome::ld2420 { 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/select/operating_mode_select.cpp b/esphome/components/ld2420/select/operating_mode_select.cpp index 82939e18d3..6d9bb24cbc 100644 --- a/esphome/components/ld2420/select/operating_mode_select.cpp +++ b/esphome/components/ld2420/select/operating_mode_select.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2420 { -static const char *const TAG = "ld2420.select"; +ESPHOME_LOG_TAG(TAG, "ld2420.select"); void LD2420Select::control(size_t index) { this->publish_state(index); 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/sensor/ld2420_sensor.cpp b/esphome/components/ld2420/sensor/ld2420_sensor.cpp index 02b5826f98..d8690a6898 100644 --- a/esphome/components/ld2420/sensor/ld2420_sensor.cpp +++ b/esphome/components/ld2420/sensor/ld2420_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2420 { -static const char *const TAG = "ld2420.sensor"; +ESPHOME_LOG_TAG(TAG, "ld2420.sensor"); void LD2420Sensor::dump_config() { ESP_LOGCONFIG(TAG, "Sensor:"); 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/ld2420/text_sensor/ld2420_text_sensor.cpp b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp index f7b016c9d9..a8360b1ccb 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ld2420 { -static const char *const TAG = "ld2420.text_sensor"; +ESPHOME_LOG_TAG(TAG, "ld2420.text_sensor"); void LD2420TextSensor::dump_config() { ESP_LOGCONFIG(TAG, "Text Sensor:"); diff --git a/esphome/components/ld2450/__init__.py b/esphome/components/ld2450/__init__.py index 4c37f4fcd1..a54e83f287 100644 --- a/esphome/components/ld2450/__init__.py +++ b/esphome/components/ld2450/__init__.py @@ -8,6 +8,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ld24xx"] DEPENDENCIES = ["uart"] CODEOWNERS = ["@hareeshmu"] +DOMAIN = "ld2450" MULTI_CONF = True ld2450_ns = cg.esphome_ns.namespace("ld2450") 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/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 4b41d63a88..b43493c26e 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -16,7 +16,7 @@ namespace esphome::ld2450 { -static const char *const TAG = "ld2450"; +ESPHOME_LOG_TAG(TAG, "ld2450"); enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, 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/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/sensor.py b/esphome/components/ld2450/sensor.py index 40462e202d..6f2056330b 100644 --- a/esphome/components/ld2450/sensor.py +++ b/esphome/components/ld2450/sensor.py @@ -46,41 +46,17 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(CONF_LD2450_ID): cv.use_id(LD2450Component), cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ACCOUNT_GROUP, ), cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_HUMAN_GREETING_PROXIMITY, ), cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ACCOUNT_SWITCH, ), } @@ -92,85 +68,37 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_X): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ALPHA_X_BOX_OUTLINE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_Y): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_ALPHA_Y_BOX_OUTLINE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_SPEED): sensor.sensor_schema( device_class=DEVICE_CLASS_SPEED, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_SPEEDOMETER_SLOW, unit_of_measurement=UNIT_MILLIMETER_PER_SECOND, ), cv.Optional(CONF_ANGLE): sensor.sensor_schema( - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, unit_of_measurement=UNIT_DEGREES, accuracy_decimals=1, ), cv.Optional(CONF_DISTANCE): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_DISTANCE, unit_of_measurement=UNIT_MILLIMETER, ), cv.Optional(CONF_RESOLUTION): sensor.sensor_schema( device_class=DEVICE_CLASS_DISTANCE, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_RELATION_ZERO_OR_ONE_TO_ZERO_OR_ONE, unit_of_measurement=UNIT_MILLIMETER, ), @@ -183,41 +111,17 @@ CONFIG_SCHEMA = CONFIG_SCHEMA.extend( { cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), cv.Optional(CONF_STILL_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), cv.Optional(CONF_MOVING_TARGET_COUNT): sensor.sensor_schema( accuracy_decimals=0, - filters=[ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, - ], + filters=sensor.TIMEOUT_THROTTLE_FILTERS, icon=ICON_MAP_MARKER_ACCOUNT, ), } @@ -228,47 +132,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/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/ld2460/__init__.py b/esphome/components/ld2460/__init__.py new file mode 100644 index 0000000000..88c3a173ef --- /dev/null +++ b/esphome/components/ld2460/__init__.py @@ -0,0 +1,47 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ON_DATA +from esphome.types import ConfigType + +AUTO_LOAD = ["ld24xx"] +DEPENDENCIES = ["uart"] +CODEOWNERS = ["@jesusvallejo"] +DOMAIN = "ld2460" +MULTI_CONF = True + +ld2460_ns = cg.esphome_ns.namespace("ld2460") +LD2460Component = ld2460_ns.class_("LD2460Component", cg.Component, uart.UARTDevice) + +CONF_LD2460_ID = "ld2460_id" + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LD2460Component), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), + } + ) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + require_tx=True, + require_rx=True, + parity="NONE", + stop_bits=1, +) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_DATA, "add_on_data_callback"), +) + + +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) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/ld2460/ld2460.cpp b/esphome/components/ld2460/ld2460.cpp new file mode 100644 index 0000000000..2efd34a1d8 --- /dev/null +++ b/esphome/components/ld2460/ld2460.cpp @@ -0,0 +1,230 @@ +#include "ld2460.h" + +#include "esphome/core/hal.h" + +#include +#include +#include + +namespace esphome::ld2460 { + +ESPHOME_LOG_TAG(TAG, "ld2460"); + +static constexpr uint8_t HEADER_FOOTER_SIZE = 4; +// PROGMEM keeps the patterns out of RAM on the ESP8266; they are copied out for each compare +static constexpr uint8_t CMD_FRAME_HEADER[HEADER_FOOTER_SIZE] PROGMEM = {0xFD, 0xFC, 0xFB, 0xFA}; +static constexpr uint8_t CMD_FRAME_FOOTER[HEADER_FOOTER_SIZE] PROGMEM = {0x04, 0x03, 0x02, 0x01}; +static constexpr uint8_t DATA_FRAME_HEADER[HEADER_FOOTER_SIZE] PROGMEM = {0xF4, 0xF3, 0xF2, 0xF1}; +static constexpr uint8_t DATA_FRAME_FOOTER[HEADER_FOOTER_SIZE] PROGMEM = {0xF8, 0xF7, 0xF6, 0xF5}; + +static constexpr uint8_t CMD_REPORT_DATA = 0x04; +static constexpr uint8_t CMD_QUERY_INSTALLATION_MODE = 0x0A; +static constexpr uint8_t ACK_FRAME_LENGTH = 12; +static constexpr uint8_t MAX_QUERY_ATTEMPTS = 3; +// Header, command, length (12, little endian), payload 0x01, footer +static constexpr uint8_t QUERY_INSTALLATION_MODE_FRAME[] PROGMEM = { + 0xFD, 0xFC, 0xFB, 0xFA, CMD_QUERY_INSTALLATION_MODE, 0x0C, 0x00, 0x01, 0x04, 0x03, 0x02, 0x01, +}; + +static bool matches(const uint8_t *bytes, const uint8_t *pattern) { + uint8_t copy[HEADER_FOOTER_SIZE]; + progmem_memcpy(copy, pattern, sizeof(copy)); + return std::memcmp(bytes, copy, sizeof(copy)) == 0; +} + +// Header (4), command (1), length (2, little endian); the length counts the whole frame +static constexpr uint8_t FRAME_PREFIX_SIZE = 7; +static uint16_t frame_length(const uint8_t *frame) { return encode_uint16(frame[6], frame[5]); } + +static constexpr float RADIANS_TO_DEGREES = 180.0f / std::numbers::pi_v; + +void LD2460Component::setup() { this->send_query_(); } + +void LD2460Component::send_query_() { + uint8_t frame[sizeof(QUERY_INSTALLATION_MODE_FRAME)]; + progmem_memcpy(frame, QUERY_INSTALLATION_MODE_FRAME, sizeof(frame)); + this->write_array(frame, sizeof(frame)); +} + +void LD2460Component::dump_config() { + ESP_LOGCONFIG(TAG, + "LD2460:\n" + " Installation mode: %s", + this->query_attempts_ != 0 ? LOG_STR_LITERAL("unknown") + : this->installation_mode_ == InstallationMode::INSTALLATION_MODE_TOP ? LOG_STR_LITERAL("top") + : LOG_STR_LITERAL("side")); +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetCount", this->target_count_sensor_); + for (const auto &t : this->target_sensors_) { + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetX", t.x); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetY", t.y); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetDistance", t.distance); + LOG_SENSOR_WITH_DEDUP_SAFE(" ", "TargetAngle", t.angle); + } +#endif +} + +void LD2460Component::loop() { + size_t avail = this->available(); + uint8_t buf[MAX_LINE_LENGTH]; + while (avail > 0) { + size_t to_read = std::min(avail, sizeof(buf)); + if (!this->read_array(buf, to_read)) { + break; + } + avail -= to_read; + + for (size_t i = 0; i < to_read; i++) { + this->readline_(buf[i]); + } + } +} + +void LD2460Component::handle_periodic_data_() { + const uint8_t *data = this->buffer_data_; + if (!this->align_to_header_(DATA_FRAME_HEADER) || data[4] != CMD_REPORT_DATA) { + ESP_LOGW(TAG, "Invalid periodic data header"); + return; + } + const uint8_t packet_len = this->buffer_pos_; + if (packet_len < 11 || (packet_len - 11) % 4 != 0) { + ESP_LOGW(TAG, "Periodic packet length %u is not a whole number of targets", packet_len); + return; + } + + if (this->query_attempts_ != 0) { + if (this->query_attempts_ < MAX_QUERY_ATTEMPTS) { + // The module may have booted after us and missed the earlier query + this->query_attempts_++; + this->send_query_(); + } else if (this->query_attempts_ == MAX_QUERY_ATTEMPTS) { + this->query_attempts_++; + ESP_LOGW(TAG, "No installation mode reply after %u queries; assuming side mount", MAX_QUERY_ATTEMPTS); + } + } + +#ifdef USE_SENSOR + uint8_t num_targets = std::min((packet_len - 11) / 4, MAX_TARGETS); + for (uint8_t i = 0; i < MAX_TARGETS; i++) { + TargetSensors &t = this->target_sensors_[i]; + if (i >= num_targets) { + t.last_x = INT16_MIN; + t.last_y = INT16_MIN; + SAFE_PUBLISH_SENSOR_UNKNOWN(t.x); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.y); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.distance); + SAFE_PUBLISH_SENSOR_UNKNOWN(t.angle); + continue; + } + const uint8_t *p = &data[7 + i * 4]; + auto raw_x = static_cast(encode_uint16(p[1], p[0])); + auto raw_y = static_cast(encode_uint16(p[3], p[2])); + if (raw_x == t.last_x && raw_y == t.last_y) { + continue; + } + t.last_x = raw_x; + t.last_y = raw_y; + + // Coordinates are reported in 0.1 m steps + float x = raw_x * 0.1f; + float y = raw_y * 0.1f; + SAFE_PUBLISH_SENSOR(t.x, x); + SAFE_PUBLISH_SENSOR(t.y, y); + if (t.distance.has_sensor()) { + SAFE_PUBLISH_SENSOR(t.distance, std::sqrt(x * x + y * y)); + } + if (t.angle.has_sensor()) { + float angle; + if (this->installation_mode_ == InstallationMode::INSTALLATION_MODE_TOP) { + angle = std::atan2(y, x) * RADIANS_TO_DEGREES; + if (angle < 0.0f) { + angle += 360.0f; + } + } else { + angle = std::atan2(x, y) * RADIANS_TO_DEGREES; + } + SAFE_PUBLISH_SENSOR(t.angle, angle); + } + } + SAFE_PUBLISH_SENSOR(this->target_count_sensor_, num_targets); +#endif + + this->data_callback_.call(); +} + +void LD2460Component::readline_(uint8_t readch) { + if (this->buffer_pos_ >= MAX_LINE_LENGTH) { + ESP_LOGW(TAG, "Max frame length exceeded; ignoring"); + this->buffer_pos_ = 0; + } + this->buffer_data_[this->buffer_pos_++] = readch; + + if (this->buffer_pos_ < HEADER_FOOTER_SIZE) { + return; + } + const uint8_t *tail = &this->buffer_data_[this->buffer_pos_ - HEADER_FOOTER_SIZE]; + bool is_data = readch == progmem_read_byte(&DATA_FRAME_FOOTER[3]) && matches(tail, DATA_FRAME_FOOTER); + if (!is_data && (readch != progmem_read_byte(&CMD_FRAME_FOOTER[3]) || !matches(tail, CMD_FRAME_FOOTER))) { + return; + } + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MAX_LINE_LENGTH)]; + ESP_LOGV(TAG, "Frame: %s", format_hex_pretty_to(hex_buf, this->buffer_data_, this->buffer_pos_)); +#endif + + if (is_data) { + this->handle_periodic_data_(); + } else { + this->handle_ack_(); + } + this->buffer_pos_ = 0; +} + +// Once a footer is matched, shift the buffer to the first offset holding a header whose length +// field ends the frame exactly at the footer. Stray bytes, truncated frames and header bytes inside +// a payload are skipped this way. Returns false when no such offset exists. +bool LD2460Component::align_to_header_(const uint8_t *header) { + for (uint8_t i = 0; i + FRAME_PREFIX_SIZE <= this->buffer_pos_; i++) { + const uint8_t *frame = &this->buffer_data_[i]; + if (matches(frame, header) && frame_length(frame) == this->buffer_pos_ - i) { + if (i != 0) { + this->buffer_pos_ -= i; + std::memmove(this->buffer_data_, frame, this->buffer_pos_); + } + return true; + } + } + return false; +} + +void LD2460Component::handle_ack_() { + const uint8_t *data = this->buffer_data_; + if (!this->align_to_header_(CMD_FRAME_HEADER) || this->buffer_pos_ != ACK_FRAME_LENGTH) { + ESP_LOGW(TAG, "Dropping malformed command frame"); + return; + } + if (data[4] != CMD_QUERY_INSTALLATION_MODE) { + ESP_LOGD(TAG, "Ignoring reply to command 0x%02X", data[4]); + return; + } + const uint8_t mode = data[7]; + if (mode != static_cast(InstallationMode::INSTALLATION_MODE_SIDE) && + mode != static_cast(InstallationMode::INSTALLATION_MODE_TOP)) { + ESP_LOGW(TAG, "Unexpected installation mode %u", mode); + return; + } + this->installation_mode_ = static_cast(mode); + this->query_attempts_ = 0; + ESP_LOGD(TAG, "Installation mode: %s", + mode == static_cast(InstallationMode::INSTALLATION_MODE_TOP) ? LOG_STR_LITERAL("top") + : LOG_STR_LITERAL("side")); +#ifdef USE_SENSOR + // Angles depend on the mode, so republish every target on the next frame + for (auto &t : this->target_sensors_) { + t.last_x = INT16_MIN; + } +#endif +} + +} // namespace esphome::ld2460 diff --git a/esphome/components/ld2460/ld2460.h b/esphome/components/ld2460/ld2460.h new file mode 100644 index 0000000000..aa7679f39a --- /dev/null +++ b/esphome/components/ld2460/ld2460.h @@ -0,0 +1,67 @@ +#pragma once + +#include "esphome/core/defines.h" +#include "esphome/components/ld24xx/ld24xx.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::ld2460 { + +static constexpr uint8_t MAX_LINE_LENGTH = 32; // Longest frame is a 5-target data frame of 31 bytes +static constexpr uint8_t MAX_TARGETS = 5; + +enum class InstallationMode : uint8_t { + INSTALLATION_MODE_SIDE = 1, + INSTALLATION_MODE_TOP = 2, +}; + +class LD2460Component : public Component, public uart::UARTDevice { +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(target_count, uint8_t) +#endif + + public: + void setup() override; + void dump_config() override; + void loop() override; + +#ifdef USE_SENSOR + void set_target_x_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].x.set_sensor(s); } + void set_target_y_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].y.set_sensor(s); } + void set_target_distance_sensor(uint8_t target, sensor::Sensor *s) { + this->target_sensors_[target].distance.set_sensor(s); + } + void set_target_angle_sensor(uint8_t target, sensor::Sensor *s) { this->target_sensors_[target].angle.set_sensor(s); } +#endif + + /// Add a callback that will be called after each successfully processed periodic data frame. + template void add_on_data_callback(F &&callback) { this->data_callback_.add(std::forward(callback)); } + + protected: + void send_query_(); + bool align_to_header_(const uint8_t *header); + void handle_periodic_data_(); + void handle_ack_(); + void readline_(uint8_t readch); + +#ifdef USE_SENSOR + // INT16_MIN marks a slot with no published coordinates, so the next report always publishes + struct TargetSensors { + ld24xx::SensorWithDedup x, y, distance, angle; + int16_t last_x{INT16_MIN}; + int16_t last_y{INT16_MIN}; + }; + std::array target_sensors_{}; +#endif + + LazyCallbackManager data_callback_; + uint8_t buffer_data_[MAX_LINE_LENGTH]; + uint8_t buffer_pos_{0}; + // The angle formula depends on how the module is mounted, so it is read from the module at boot + InstallationMode installation_mode_{InstallationMode::INSTALLATION_MODE_SIDE}; + // Queries sent so far; 0 once the module answered. Data frames resend up to MAX_QUERY_ATTEMPTS. + uint8_t query_attempts_{1}; +}; + +} // namespace esphome::ld2460 diff --git a/esphome/components/ld2460/sensor.py b/esphome/components/ld2460/sensor.py new file mode 100644 index 0000000000..c9d4354e7a --- /dev/null +++ b/esphome/components/ld2460/sensor.py @@ -0,0 +1,87 @@ +import esphome.codegen as cg +from esphome.components import sensor +from esphome.components.const import CONF_TARGET_COUNT +import esphome.config_validation as cv +from esphome.const import ( + CONF_ANGLE, + CONF_DISTANCE, + CONF_X, + CONF_Y, + DEVICE_CLASS_DISTANCE, + UNIT_DEGREES, + UNIT_METER, +) +from esphome.types import ConfigType + +from . import CONF_LD2460_ID, LD2460Component + +DEPENDENCIES = ["ld2460"] + +ICON_ACCOUNT_GROUP = "mdi:account-group" +ICON_ALPHA_X_BOX_OUTLINE = "mdi:alpha-x-box-outline" +ICON_ALPHA_Y_BOX_OUTLINE = "mdi:alpha-y-box-outline" +ICON_FORMAT_TEXT_ROTATION_ANGLE_UP = "mdi:format-text-rotation-angle-up" +ICON_MAP_MARKER_DISTANCE = "mdi:map-marker-distance" + +MAX_TARGETS = 5 + +_TARGET_SCHEMA = cv.Schema( + { + cv.Optional(CONF_X): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_ALPHA_X_BOX_OUTLINE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=1, + ), + cv.Optional(CONF_Y): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_ALPHA_Y_BOX_OUTLINE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=1, + ), + cv.Optional(CONF_DISTANCE): sensor.sensor_schema( + device_class=DEVICE_CLASS_DISTANCE, + icon=ICON_MAP_MARKER_DISTANCE, + unit_of_measurement=UNIT_METER, + accuracy_decimals=2, + ), + cv.Optional(CONF_ANGLE): sensor.sensor_schema( + icon=ICON_FORMAT_TEXT_ROTATION_ANGLE_UP, + unit_of_measurement=UNIT_DEGREES, + accuracy_decimals=1, + ), + } +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LD2460_ID): cv.use_id(LD2460Component), + cv.Optional(CONF_TARGET_COUNT): sensor.sensor_schema( + accuracy_decimals=0, + icon=ICON_ACCOUNT_GROUP, + ), + **{ + cv.Optional(f"target_{n}"): _TARGET_SCHEMA + for n in range(1, MAX_TARGETS + 1) + }, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LD2460_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor) + + setters = ( + (CONF_X, hub.set_target_x_sensor), + (CONF_Y, hub.set_target_y_sensor), + (CONF_DISTANCE, hub.set_target_distance_sensor), + (CONF_ANGLE, hub.set_target_angle_sensor), + ) + for n in range(MAX_TARGETS): + if (target_conf := config.get(f"target_{n + 1}")) is None: + continue + for key, setter in setters: + if (sens_conf := target_conf.get(key)) is not None: + cg.add(setter(n, await sensor.new_sensor(sens_conf))) diff --git a/esphome/components/ld24xx/__init__.py b/esphome/components/ld24xx/__init__.py index 516af84856..97ff42626c 100644 --- a/esphome/components/ld24xx/__init__.py +++ b/esphome/components/ld24xx/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@kbx81"] +DOMAIN = "ld24xx" diff --git a/esphome/components/ld6002b/__init__.py b/esphome/components/ld6002b/__init__.py index af1e501a6a..c7529f9314 100644 --- a/esphome/components/ld6002b/__init__.py +++ b/esphome/components/ld6002b/__init__.py @@ -9,6 +9,7 @@ from .const import CONF_AUTO_WAKE, CONF_WAKEUP_PULSE CODEOWNERS = ["@hepter"] DEPENDENCIES = ["uart"] +DOMAIN = "ld6002b" MULTI_CONF = True ld6002b_ns = cg.esphome_ns.namespace("ld6002b") 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/button/__init__.py b/esphome/components/ld6002b/button/__init__.py index a664890a86..a33e706eb6 100644 --- a/esphome/components/ld6002b/button/__init__.py +++ b/esphome/components/ld6002b/button/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import button +from esphome.components.select import DOMAIN as SELECT_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_AREA_ID, @@ -91,7 +92,7 @@ def final_validate(config: ConfigType) -> None: if config.get(CONF_APPLY_AREA): has_area_id_select = any( entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None - for entry in full_config.get("select", []) + for entry in full_config.get(SELECT_DOMAIN, []) ) if not has_area_id_select: raise cv.Invalid( diff --git a/esphome/components/ld6002b/ld6002b.cpp b/esphome/components/ld6002b/ld6002b.cpp index aa34ad9d39..93a2ea341f 100644 --- a/esphome/components/ld6002b/ld6002b.cpp +++ b/esphome/components/ld6002b/ld6002b.cpp @@ -8,7 +8,7 @@ namespace esphome::ld6002b { -static const char *const TAG = "ld6002b"; +ESPHOME_LOG_TAG(TAG, "ld6002b"); static constexpr uint8_t TF_SOF = 0x01; static constexpr uint32_t SETUP_DELAY_MS = 100; @@ -620,7 +620,7 @@ void LD6002BComponent::handle_frame_(uint16_t type, const uint8_t *data, uint16_ this->process_command_queue_(); if (refresh_areas) { this->area_write_in_flight_ = false; - this->set_timeout(AREA_REFRESH_TIMEOUT, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); + this->set_timeout(AREA_REFRESH_TIMEOUT_ID, 50, [this]() { this->send_control_command_(CMD_GET_AREAS); }); } return; } @@ -1215,7 +1215,7 @@ void LD6002BComponent::send_command_internal_(uint16_t type, const uint8_t *data std::memcpy(this->wake_scratch_.data(), data, len); } // A button pulse must not raise the pin in the middle of this one. - this->cancel_timeout(WAKE_BUTTON_TIMEOUT); + this->cancel_timeout(WAKE_BUTTON_TIMEOUT_ID); this->wake_pulse_pending_ = true; this->wakeup_pin_->digital_write(false); const uint8_t generation = this->send_generation_; @@ -1364,7 +1364,8 @@ void LD6002BComponent::wake_() { if (this->wakeup_pin_ == nullptr || this->wake_pulse_pending_) return; this->wakeup_pin_->digital_write(false); - this->set_timeout(WAKE_BUTTON_TIMEOUT, this->wakeup_pulse_ms_, [this]() { this->wakeup_pin_->digital_write(true); }); + this->set_timeout(WAKE_BUTTON_TIMEOUT_ID, this->wakeup_pulse_ms_, + [this]() { this->wakeup_pin_->digital_write(true); }); } void LD6002BComponent::set_number_value(NumberType type, float value) { diff --git a/esphome/components/ld6002b/ld6002b.h b/esphome/components/ld6002b/ld6002b.h index bea3804312..c52f770d6e 100644 --- a/esphome/components/ld6002b/ld6002b.h +++ b/esphome/components/ld6002b/ld6002b.h @@ -413,12 +413,12 @@ class LD6002BComponent : public Component, public uart::UARTDevice { // How long the module stays awake after any frame, and so still answers the next one. static constexpr uint32_t MODULE_AWAKE_MS = 10000; static constexpr uint8_t CMD_MAX_RETRIES = 3; - // Named so a repeated press replaces its own pending timeout instead of stacking + // Keyed so a repeated press replaces its own pending timeout instead of stacking // another, and so the command path can cancel it when it takes the pin over. - static constexpr const char *WAKE_BUTTON_TIMEOUT = "wake_button"; - // Named so a burst of writes collapses to one read once they settle, rather than + static constexpr uint32_t WAKE_BUTTON_TIMEOUT_ID = 0; + // Keyed so a burst of writes collapses to one read once they settle, rather than // one read per write. - static constexpr const char *AREA_REFRESH_TIMEOUT = "area_refresh"; + static constexpr uint32_t AREA_REFRESH_TIMEOUT_ID = 1; // A reply cannot trail the frame that earned it for longer than this; the field worst case is ~726ms. static constexpr uint32_t STALE_ACK_MAX_AGE_MS = 1000; diff --git a/esphome/components/ld6002b/number/__init__.py b/esphome/components/ld6002b/number/__init__.py index 236b049f53..de0ff57a64 100644 --- a/esphome/components/ld6002b/number/__init__.py +++ b/esphome/components/ld6002b/number/__init__.py @@ -1,9 +1,10 @@ import esphome.codegen as cg from esphome.components import number +from esphome.components.button import DOMAIN as BUTTON_DOMAIN +from esphome.components.select import DOMAIN as SELECT_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_AREA_ID, - CONF_BUTTON, DEVICE_CLASS_DISTANCE, DEVICE_CLASS_DURATION, ENTITY_CATEGORY_CONFIG, @@ -114,7 +115,7 @@ def final_validate(config: ConfigType) -> None: has_apply_area = any( entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_APPLY_AREA) is not None - for entry in full_config.get(CONF_BUTTON, []) + for entry in full_config.get(BUTTON_DOMAIN, []) ) if not has_apply_area: raise cv.Invalid( @@ -124,7 +125,7 @@ def final_validate(config: ConfigType) -> None: has_area_id_select = any( entry.get(CONF_LD6002B_ID) == hub_id and entry.get(CONF_AREA_ID) is not None - for entry in full_config.get("select", []) + for entry in full_config.get(SELECT_DOMAIN, []) ) if not has_area_id_select: raise cv.Invalid( diff --git a/esphome/components/ld6002b/sensor.py b/esphome/components/ld6002b/sensor.py index cceefb3837..d2285187f6 100644 --- a/esphome/components/ld6002b/sensor.py +++ b/esphome/components/ld6002b/sensor.py @@ -30,44 +30,32 @@ from .const import ( DEPENDENCIES = ["ld6002b"] -# The ld2450 defaults for a streamed value: hold the last reading for a second so a -# dropped frame does not read as absence, then rate-limit what reaches the frontend. -_VALUE_SENSOR_FILTERS = [ - { - "timeout": { - "timeout": cv.TimePeriod(milliseconds=1000), - "value": "last", - } - }, - {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, -] - TARGET_SCHEMA = cv.Schema( { cv.Optional(CONF_X): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_Y): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_Z): sensor.sensor_schema( unit_of_measurement=UNIT_METER, accuracy_decimals=2, device_class=DEVICE_CLASS_DISTANCE, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_DOPPLER_INDEX): sensor.sensor_schema( accuracy_decimals=0, - filters=_VALUE_SENSOR_FILTERS, + filters=sensor.TIMEOUT_THROTTLE_FILTERS, state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_CLUSTER_ID): sensor.sensor_schema( @@ -154,13 +142,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/__init__.py b/esphome/components/ledc/__init__.py index 71a87b6ae5..cc2c4d56aa 100644 --- a/esphome/components/ledc/__init__.py +++ b/esphome/components/ledc/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "ledc" diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 62833a7649..18ce19a183 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -22,7 +22,7 @@ static const uint8_t SETUP_ATTEMPT_COUNT_MAX = 5; namespace esphome::ledc { -static const char *const TAG = "ledc.output"; +ESPHOME_LOG_TAG(TAG, "ledc.output"); static bool ledc_peripheral_reset_done = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static const int MAX_RES_BITS = LEDC_TIMER_BIT_MAX - 1; diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index de63325455..1df0f67572 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -35,11 +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; }; } // namespace esphome::ledc diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index 8cd7f3e7b6..61eafe5f86 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, @@ -56,6 +57,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["preferences"] +DOMAIN = "libretiny" IS_TARGET_PLATFORM = True # BLE 5.x BK SDK options to disable unused features. @@ -490,6 +492,13 @@ 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") + # Static destructors never run, so skip registering them. See atexit_stubs.cpp. + cg.add_build_flag("-Wl,--wrap=__cxa_atexit") + + # 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 @@ -521,6 +530,10 @@ async def component_to_code(config): # 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) diff --git a/esphome/components/libretiny/atexit_stubs.cpp b/esphome/components/libretiny/atexit_stubs.cpp new file mode 100644 index 0000000000..1d237e7f05 --- /dev/null +++ b/esphome/components/libretiny/atexit_stubs.cpp @@ -0,0 +1,20 @@ +/* + * Linker wrap stub for __cxa_atexit(). + * + * GCC registers the destructor of every global and function local static + * object with __cxa_atexit(). The firmware never returns from main or calls + * exit(), so these destructors never run, but the first registration links + * newlib's exit handler tables. + */ + +#include "esphome/core/defines.h" + +#ifdef USE_LIBRETINY + +namespace esphome::libretiny {} + +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" int __wrap___cxa_atexit(void (*)(void *), void *, void *) { return 0; } +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +#endif // USE_LIBRETINY 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..9884c37b85 100644 --- a/esphome/components/libretiny/gpio_arduino.cpp +++ b/esphome/components/libretiny/gpio_arduino.cpp @@ -1,13 +1,16 @@ #ifdef USE_LIBRETINY #include "gpio_arduino.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome::libretiny { -static const char *const TAG = "libretiny.gpio"; +ESPHOME_LOG_TAG(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) { @@ -35,7 +38,7 @@ ISRInternalGPIOPin ArduinoInternalGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ArduinoInternalGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ArduinoInternalGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = (PinStatus) 255; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/libretiny/gpio_arduino.h b/esphome/components/libretiny/gpio_arduino.h index da477fde36..b5ccf544ba 100644 --- a/esphome/components/libretiny/gpio_arduino.h +++ b/esphome/components/libretiny/gpio_arduino.h @@ -1,11 +1,11 @@ #pragma once #ifdef USE_LIBRETINY -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::libretiny { -class ArduinoInternalGPIOPin final : public InternalGPIOPin { +class ArduinoInternalGPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -16,14 +16,18 @@ class ArduinoInternalGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; 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/hal.h b/esphome/components/libretiny/hal.h index 48b94a5214..838536b866 100644 --- a/esphome/components/libretiny/hal.h +++ b/esphome/components/libretiny/hal.h @@ -8,6 +8,7 @@ #include #include +#include "esphome/core/main_task.h" #include "esphome/core/time_64.h" // IRAM_ATTR places a function in executable RAM so it is callable from an @@ -84,6 +85,10 @@ __attribute__((always_inline)) inline bool in_isr_context() { #endif } +/// Before setup() stores the handle every task counts as background; the wake is a no-op until then. +__attribute__((always_inline)) inline bool is_main_loop_thread() { + return xTaskGetCurrentTaskHandle() == esphome_main_task_handle; +} __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } 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..9cc7b5ff68 100644 --- a/esphome/components/libretiny/lt_component.cpp +++ b/esphome/components/libretiny/lt_component.cpp @@ -2,11 +2,14 @@ #ifdef USE_LIBRETINY +#include + +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::libretiny { -static const char *const TAG = "libretiny"; +ESPHOME_LOG_TAG(TAG, "libretiny"); void LTComponent::dump_config() { ESP_LOGCONFIG(TAG, @@ -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/preferences.cpp b/esphome/components/libretiny/preferences.cpp index d0bd3bf26b..a868e5042e 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -8,7 +8,7 @@ namespace esphome::libretiny { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); struct NVSData { uint32_t key; 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/__init__.py b/esphome/components/libretiny_pwm/__init__.py index 4db724f8ad..1825606769 100644 --- a/esphome/components/libretiny_pwm/__init__.py +++ b/esphome/components/libretiny_pwm/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@kuba2k2"] +DOMAIN = "libretiny_pwm" diff --git a/esphome/components/libretiny_pwm/libretiny_pwm.cpp b/esphome/components/libretiny_pwm/libretiny_pwm.cpp index eea593a39d..ee6fd5bb0e 100644 --- a/esphome/components/libretiny_pwm/libretiny_pwm.cpp +++ b/esphome/components/libretiny_pwm/libretiny_pwm.cpp @@ -5,7 +5,7 @@ namespace esphome::libretiny_pwm { -static const char *const TAG = "libretiny.pwm"; +ESPHOME_LOG_TAG(TAG, "libretiny.pwm"); void LibreTinyPWM::write_state(float state) { if (!this->initialized_) { diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index ab9624c364..c490300b58 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,23 +28,20 @@ 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, setup_entity, ) -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass import esphome.final_validate as fv from esphome.types import ConfigType @@ -64,11 +54,24 @@ 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, + LightEffect, LightOutput, LightState, LightStateRTCState, @@ -84,6 +87,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 +144,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 +289,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 +361,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 +372,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 +396,7 @@ LIGHT_SCHEMA = ( } ), cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA, + cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean, } ) ) @@ -393,6 +418,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 +436,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 +459,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 +496,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): @@ -474,33 +520,59 @@ def validate_color_temperature_channels(value): return value +def _effects_table(effects: list[MockObj]) -> MockObj: + """Emit the flash table of a light's effect pointers; each must be a static address.""" + return cg.shared_progmem_array( + "light_effects", + LightEffect.operator("ptr"), + cg.ArrayInitializer(*effects), + share=False, + constexpr=False, + ) + + @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,17 +584,21 @@ 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( EFFECTS_REGISTRY, config.get(CONF_EFFECTS, []) ) if effects: - cg.add(light_var.add_effects(effects)) + cg.add(light_var.add_effects(_effects_table(effects), len(effects))) for conf in config.get(CONF_ON_TURN_ON, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], light_var) diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index d2f5913f4b..a6440700fa 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -3,7 +3,7 @@ namespace esphome::light { -static const char *const TAG = "light.addressable"; +ESPHOME_LOG_TAG(TAG, "light.addressable"); void AddressableLight::call_setup() { this->setup(); diff --git a/esphome/components/light/automation.cpp b/esphome/components/light/automation.cpp index ddac2f9341..35aa035b26 100644 --- a/esphome/components/light/automation.cpp +++ b/esphome/components/light/automation.cpp @@ -3,7 +3,7 @@ namespace esphome::light { -static const char *const TAG = "light.automation"; +ESPHOME_LOG_TAG(TAG, "light.automation"); void addressableset_warn_about_scale(const char *field) { ESP_LOGW(TAG, "Lambda for parameter %s of light.addressable_set should return values in range 0-1 instead of 0-255.", diff --git a/esphome/components/light/automation.py b/esphome/components/light/automation.py index 5ae2660774..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, @@ -28,6 +34,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, EsphomeError, Lambda from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor from esphome.types import ConfigType from .types import ( @@ -42,8 +49,32 @@ from .types import ( 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", @@ -71,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( { @@ -342,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( 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/esp_color_view.h b/esphome/components/light/esp_color_view.h index 440a23e9c9..018e30a9a8 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -19,10 +19,7 @@ class ESPColorSettable { virtual void lighten(uint8_t delta) = 0; virtual void darken(uint8_t delta) = 0; void set(const ESPHSVColor &color) { this->set_hsv(color); } - void set_hsv(const ESPHSVColor &color) { - Color rgb = color.to_rgb(); - this->set_rgb(rgb.r, rgb.g, rgb.b); - } + void set_hsv(const ESPHSVColor &color) { this->set(color.to_rgb()); } void set_rgb(uint8_t red, uint8_t green, uint8_t blue) { this->set_red(red); this->set_green(green); diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 4251565e85..0970c513a2 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -8,7 +8,7 @@ namespace esphome::light { -static const char *const TAG = "light"; +ESPHOME_LOG_TAG(TAG, "light"); // Cold-path logger; caller handles the clamp so the in-range hot path avoids // the spill/reload around the call. @@ -147,7 +147,7 @@ void LightCall::perform() { // EFFECT StringRef effect_s; if (this->effect_ == 0u) { - effect_s = StringRef::from_lit("None"); + effect_s = EFFECT_NONE_REF; } else { effect_s = this->parent_->effects_[this->effect_ - 1]->get_name(); } @@ -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_effect.h b/esphome/components/light/light_effect.h index a89e3fec5a..3fabcf5d51 100644 --- a/esphome/components/light/light_effect.h +++ b/esphome/components/light/light_effect.h @@ -5,6 +5,8 @@ namespace esphome::light { +inline constexpr StringRef EFFECT_NONE_REF = StringRef::from_lit("None"); + class LightState; class LightEffect { diff --git a/esphome/components/light/light_effect_names.h b/esphome/components/light/light_effect_names.h new file mode 100644 index 0000000000..7ddf459b24 --- /dev/null +++ b/esphome/components/light/light_effect_names.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" +#include "light_effect.h" + +namespace esphome::light { + +/// Effect names as the native API lists them: "None" first, then each effect; empty without effects. +/// Reads the light's effect table directly, so listing entities allocates nothing. Holds a reference: +/// it only lives for one synchronous encode, and by value costs flash on every copy. +class LightEffectNames { + public: + explicit LightEffectNames(const ConstVector &effects) : effects_(effects) {} + + class Iterator { + public: + ESPHOME_ALWAYS_INLINE Iterator(const ConstVector &effects, size_t index) + : effects_(effects), index_(index) {} + ESPHOME_ALWAYS_INLINE StringRef operator*() const { + return this->index_ == 0 ? EFFECT_NONE_REF : this->effects_[this->index_ - 1]->get_name(); + } + ESPHOME_ALWAYS_INLINE Iterator &operator++() { + ++this->index_; + return *this; + } + ESPHOME_ALWAYS_INLINE bool operator!=(const Iterator &other) const { return this->index_ != other.index_; } + + protected: + const ConstVector &effects_; + size_t index_; + }; + + ESPHOME_ALWAYS_INLINE Iterator begin() const { return {this->effects_, 0}; } + ESPHOME_ALWAYS_INLINE Iterator end() const { + return {this->effects_, this->effects_.empty() ? 0 : this->effects_.size() + 1}; + } + ESPHOME_ALWAYS_INLINE bool empty() const { return this->effects_.empty(); } + + protected: + const ConstVector &effects_; +}; + +} // namespace esphome::light diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 8624fade16..40e9c0b1aa 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -27,17 +27,17 @@ static ProgmemStr get_color_mode_json_str(ColorMode mode) { void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) { - root[ESPHOME_F("effect")] = state.get_effect_name().c_str(); + root[ESPHOME_F("effect")] = json::linked(state.get_effect_name()); root[ESPHOME_F("effect_index")] = state.get_current_effect_index(); 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); if (mode_str != nullptr) { - root[ESPHOME_F("color_mode")] = mode_str; + root[ESPHOME_F("color_mode")] = json::linked(mode_str); } if (color_mode & ColorCapability::ON_OFF) diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 82c00e2382..885440187f 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -1,6 +1,9 @@ #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" @@ -8,7 +11,21 @@ namespace esphome::light { -static const char *const TAG = "light"; +ESPHOME_LOG_TAG(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) {} @@ -37,38 +54,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 +72,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 +94,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 +137,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(); @@ -170,8 +194,6 @@ void LightState::publish_state() { LightOutput *LightState::get_output() const { return this->output_; } -static constexpr auto EFFECT_NONE_REF = StringRef::from_lit("None"); - StringRef LightState::get_effect_name() { if (this->active_effect_index_ > 0) { return this->effects_[this->active_effect_index_ - 1]->get_name(); @@ -192,11 +214,6 @@ void LightState::add_target_state_reached_listener(LightTargetStateReachedListen this->target_state_reached_listeners_->push_back(listener); } -void LightState::add_effects(const std::initializer_list &effects) { - // Called once from Python codegen during setup with all effects from YAML config - this->effects_ = effects; -} - void LightState::current_values_as_brightness(float *brightness) { this->current_values.as_brightness(brightness); *brightness = this->gamma_correct_lut(*brightness); @@ -279,6 +296,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 +315,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 +329,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); @@ -322,7 +347,7 @@ void LightState::start_effect_(uint32_t effect_index) { 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 +373,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 +387,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 +395,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 +413,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 +443,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..2a99943e1d 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,21 +199,31 @@ 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(); } /// Get all effects for this light state. - const FixedVector &get_effects() const { return this->effects_; } + const ConstVector &get_effects() const { return this->effects_; } - /// Add effects for this light state. - void add_effects(const std::initializer_list &effects); + /// Codegen only: point at the generated table of this light's effects, which must outlive it. + /// Codegen caps the count at 65535, so an effect index always fits the uint16_t active index. + void add_effects(LightEffect *const *effects, size_t count) { + this->effects_ = ConstVector(effects, count); + } /// Get the total number of effects available for this light. size_t get_effect_count() const { return this->effects_.size(); } @@ -291,6 +311,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 +328,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_(); @@ -324,7 +357,7 @@ class LightState : public EntityBase, public Component { /// The currently active transformer for this light (transition/flash). std::unique_ptr transformer_{nullptr}; /// List of effects for this light. - FixedVector effects_; + ConstVector effects_; /// Object used to store the persisted values of the light. ESPPreferenceObject rtc_; @@ -347,28 +380,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 ceb974f26d..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") diff --git a/esphome/components/lightwaverf/__init__.py b/esphome/components/lightwaverf/__init__.py index 0f42083cb5..1a4234ceaf 100644 --- a/esphome/components/lightwaverf/__init__.py +++ b/esphome/components/lightwaverf/__init__.py @@ -11,12 +11,11 @@ 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 CODEOWNERS = ["@max246"] +DOMAIN = "lightwaverf" lightwaverf_ns = cg.esphome_ns.namespace("lightwaverf") @@ -24,7 +23,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 +34,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.cpp b/esphome/components/lightwaverf/lightwaverf.cpp index b8b6a9697e..58c4ab0784 100644 --- a/esphome/components/lightwaverf/lightwaverf.cpp +++ b/esphome/components/lightwaverf/lightwaverf.cpp @@ -7,7 +7,7 @@ namespace esphome::lightwaverf { -static const char *const TAG = "lightwaverf.sensor"; +ESPHOME_LOG_TAG(TAG, "lightwaverf.sensor"); static const uint8_t DEFAULT_REPEAT = 10; static const bool DEFAULT_INVERT = false; 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/lilygo_t5_47/__init__.py b/esphome/components/lilygo_t5_47/__init__.py index 5499d096a9..cb3605c63e 100644 --- a/esphome/components/lilygo_t5_47/__init__.py +++ b/esphome/components/lilygo_t5_47/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "lilygo_t5_47" + lilygo_t5_47_ns = cg.esphome_ns.namespace("lilygo_t5_47") diff --git a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp index 87319235e9..d76f4dfb39 100644 --- a/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp +++ b/esphome/components/lilygo_t5_47/touchscreen/lilygo_t5_47_touchscreen.cpp @@ -5,7 +5,7 @@ namespace esphome::lilygo_t5_47 { -static const char *const TAG = "lilygo_t5_47.touchscreen"; +ESPHOME_LOG_TAG(TAG, "lilygo_t5_47.touchscreen"); static const uint8_t POWER_REGISTER = 0xD6; static const uint8_t TOUCH_REGISTER = 0xD0; diff --git a/esphome/components/lis2dw12/__init__.py b/esphome/components/lis2dw12/__init__.py new file mode 100644 index 0000000000..fcd885e5db --- /dev/null +++ b/esphome/components/lis2dw12/__init__.py @@ -0,0 +1,181 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_CALIBRATION, + CONF_ID, + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_OFFSET_X, + CONF_OFFSET_Y, + CONF_OFFSET_Z, + CONF_POWER_MODE, + CONF_RANGE, + CONF_SWAP_XY, + CONF_TRANSFORM, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +DOMAIN = "lis2dw12" + +CONF_LIS2DW12_ID = "lis2dw12_id" + +CONF_MIRROR_Z = "mirror_z" +CONF_ON_ACTIVE = "on_active" +CONF_ON_DOUBLE_TAP = "on_double_tap" +CONF_ON_FREEFALL = "on_freefall" +CONF_ON_ORIENTATION = "on_orientation" +CONF_ON_TAP = "on_tap" + +CONF_LOW_NOISE = "low_noise" +CONF_FILTER_BANDWIDTH = "filter_bandwidth" +CONF_OUTPUT_DATA_RATE = "output_data_rate" + +lis2dw12_ns = cg.esphome_ns.namespace("lis2dw12") +LIS2DW12Component = lis2dw12_ns.class_("LIS2DW12Component", cg.PollingComponent) + +LIS2DW12Range = lis2dw12_ns.enum("Range", True) +RANGES = { + "2G": LIS2DW12Range.RANGE_2G, + "4G": LIS2DW12Range.RANGE_4G, + "8G": LIS2DW12Range.RANGE_8G, + "16G": LIS2DW12Range.RANGE_16G, +} + +LIS2DW12PowerMode = lis2dw12_ns.enum("PowerMode", True) +POWER_MODES = { + "high_performance": LIS2DW12PowerMode.HIGH_PERF, + "low_power_1": LIS2DW12PowerMode.LOW_POWER_1, + "low_power_2": LIS2DW12PowerMode.LOW_POWER_2, + "low_power_3": LIS2DW12PowerMode.LOW_POWER_3, + "low_power_4": LIS2DW12PowerMode.LOW_POWER_4, +} + +LIS2DW12DataRate = lis2dw12_ns.enum("DataRate", True) +DATA_RATES = { + "1.6Hz": LIS2DW12DataRate.RATE_1_6HZ, + "12.5Hz": LIS2DW12DataRate.RATE_12_5HZ, + "25Hz": LIS2DW12DataRate.RATE_25HZ, + "50Hz": LIS2DW12DataRate.RATE_50HZ, + "100Hz": LIS2DW12DataRate.RATE_100HZ, + "200Hz": LIS2DW12DataRate.RATE_200HZ, + "400Hz": LIS2DW12DataRate.RATE_400HZ, + "800Hz": LIS2DW12DataRate.RATE_800HZ, + "1600Hz": LIS2DW12DataRate.RATE_1600HZ, +} + +LIS2DW12FilterBandwidth = lis2dw12_ns.enum("FilterBandwidth", True) +FILTER_BANDWIDTHS = { + "odr_div_2": LIS2DW12FilterBandwidth.BW_ODR_DIV_2, + "odr_div_4": LIS2DW12FilterBandwidth.BW_ODR_DIV_4, + "odr_div_10": LIS2DW12FilterBandwidth.BW_ODR_DIV_10, + "odr_div_20": LIS2DW12FilterBandwidth.BW_ODR_DIV_20, +} + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation(CONF_ON_TAP, "add_on_tap_callback"), + automation.CallbackAutomation(CONF_ON_DOUBLE_TAP, "add_on_double_tap_callback"), + automation.CallbackAutomation(CONF_ON_FREEFALL, "add_on_freefall_callback"), + automation.CallbackAutomation(CONF_ON_ACTIVE, "add_on_active_callback"), + automation.CallbackAutomation(CONF_ON_ORIENTATION, "add_on_orientation_callback"), +) + +_OFFSET = cv.float_range(min=-4.5, max=4.5) + +# The ODR code means different rates per power mode: 0001 is 1.6 Hz only in low-power modes +# (12.5 Hz in high performance) and every code above 200 Hz runs at 200 Hz in low-power modes +_LOW_POWER_ONLY_RATES = ("1.6Hz",) +_HIGH_PERF_ONLY_RATES = ("400Hz", "800Hz", "1600Hz") + + +def validate_data_rate(config: ConfigType) -> ConfigType: + """Reject rates the chip cannot run in the chosen power mode; apply after extending the base schema.""" + rate = str(config[CONF_OUTPUT_DATA_RATE]) + mode = str(config[CONF_POWER_MODE]) + high_perf = mode == "high_performance" + if (high_perf and rate in _LOW_POWER_ONLY_RATES) or ( + not high_perf and rate in _HIGH_PERF_ONLY_RATES + ): + raise cv.Invalid( + f"{CONF_OUTPUT_DATA_RATE} {rate} is not available with {CONF_POWER_MODE} {mode}", + [CONF_OUTPUT_DATA_RATE], + ) + return config + + +CONFIG_SCHEMA_BASE = cv.Schema( + { + cv.GenerateID(): cv.declare_id(LIS2DW12Component), + cv.Optional(CONF_RANGE, default="2G"): cv.enum(RANGES, upper=True), + cv.Optional(CONF_POWER_MODE, default="high_performance"): cv.enum( + POWER_MODES, lower=True + ), + cv.Optional(CONF_OUTPUT_DATA_RATE, default="100Hz"): cv.enum(DATA_RATES), + cv.Optional(CONF_FILTER_BANDWIDTH, default="odr_div_2"): cv.enum( + FILTER_BANDWIDTHS, lower=True + ), + cv.Optional(CONF_LOW_NOISE, default=False): cv.boolean, + cv.Optional(CONF_CALIBRATION): cv.Schema( + { + cv.Optional(CONF_OFFSET_X, default=0): _OFFSET, + cv.Optional(CONF_OFFSET_Y, default=0): _OFFSET, + cv.Optional(CONF_OFFSET_Z, default=0): _OFFSET, + } + ), + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Optional(CONF_MIRROR_X, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Y, default=False): cv.boolean, + cv.Optional(CONF_MIRROR_Z, default=False): cv.boolean, + cv.Optional(CONF_SWAP_XY, default=False): cv.boolean, + } + ), + cv.Optional(CONF_ON_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_DOUBLE_TAP): automation.validate_automation({}), + cv.Optional(CONF_ON_FREEFALL): automation.validate_automation({}), + cv.Optional(CONF_ON_ACTIVE): automation.validate_automation({}), + cv.Optional(CONF_ON_ORIENTATION): automation.validate_automation({}), + } +).extend(cv.polling_component_schema("10s")) + +LIS2DW12_SENSOR_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_LIS2DW12_ID): cv.use_id(LIS2DW12Component), + } +) + + +async def to_code_base(config: ConfigType) -> MockObj: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_range(config[CONF_RANGE])) + cg.add(var.set_power_mode(config[CONF_POWER_MODE])) + cg.add(var.set_output_data_rate(config[CONF_OUTPUT_DATA_RATE])) + cg.add(var.set_filter_bandwidth(config[CONF_FILTER_BANDWIDTH])) + cg.add(var.set_low_noise(config[CONF_LOW_NOISE])) + + if transform := config.get(CONF_TRANSFORM): + cg.add( + var.set_transform( + transform[CONF_MIRROR_X], + transform[CONF_MIRROR_Y], + transform[CONF_MIRROR_Z], + transform[CONF_SWAP_XY], + ) + ) + + if calibration_config := config.get(CONF_CALIBRATION): + cg.add( + var.set_offset( + calibration_config[CONF_OFFSET_X], + calibration_config[CONF_OFFSET_Y], + calibration_config[CONF_OFFSET_Z], + ) + ) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + return var diff --git a/esphome/components/lis2dw12/binary_sensor.py b/esphome/components/lis2dw12/binary_sensor.py new file mode 100644 index 0000000000..3ff0e8f15c --- /dev/null +++ b/esphome/components/lis2dw12/binary_sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ACTIVE, CONF_NAME, DEVICE_CLASS_VIBRATION, ICON_VIBRATE +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +CONF_DOUBLE_TAP = "double_tap" +CONF_FREEFALL = "freefall" +CONF_TAP = "tap" + +ICON_DOUBLE_TAP = "mdi:gesture-double-tap" +ICON_FREEFALL = "mdi:parachute" +ICON_TAP = "mdi:gesture-tap" + +EVENT_SENSORS = (CONF_TAP, CONF_DOUBLE_TAP, CONF_FREEFALL, CONF_ACTIVE) +ICONS = (ICON_TAP, ICON_DOUBLE_TAP, ICON_FREEFALL, ICON_VIBRATE) + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + { + cv.Optional(event): cv.maybe_simple_value( + binary_sensor.binary_sensor_schema( + device_class=DEVICE_CLASS_VIBRATION, + icon=icon, + ), + key=CONF_NAME, + ) + for event, icon in zip(EVENT_SENSORS, ICONS, strict=True) + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + sensors = binary_sensor.sub_binary_sensors(config) + await sensors(CONF_TAP, hub.set_tap_binary_sensor) + await sensors(CONF_DOUBLE_TAP, hub.set_double_tap_binary_sensor) + await sensors(CONF_FREEFALL, hub.set_freefall_binary_sensor) + await sensors(CONF_ACTIVE, hub.set_active_binary_sensor) diff --git a/esphome/components/lis2dw12/lis2dw12.cpp b/esphome/components/lis2dw12/lis2dw12.cpp new file mode 100644 index 0000000000..38065aa785 --- /dev/null +++ b/esphome/components/lis2dw12/lis2dw12.cpp @@ -0,0 +1,350 @@ +#include "lis2dw12.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12 { + +ESPHOME_LOG_TAG(TAG, "lis2dw12"); + +static constexpr uint8_t LIS2DW12_WHO_AM_I_VALUE = 0x44; +static constexpr float GRAVITY_EARTH = 9.80665f; +static constexpr uint32_t EVENT_COOLDOWN_MS = 500; +static constexpr uint32_t POLL_INTERVAL_MS = 50; +static constexpr uint8_t SIXD_POSITION_MASK = 0x3F; // XL..ZH, leaving out D6D_IA + +static const LogString *power_mode_to_string(PowerMode mode) { + switch (mode) { + case PowerMode::HIGH_PERF: + return LOG_STR("High Performance"); + case PowerMode::LOW_POWER_1: + return LOG_STR("Low Power 1 (12-bit)"); + case PowerMode::LOW_POWER_2: + return LOG_STR("Low Power 2"); + case PowerMode::LOW_POWER_3: + return LOG_STR("Low Power 3"); + case PowerMode::LOW_POWER_4: + return LOG_STR("Low Power 4"); + } + return LOG_STR("Unknown"); +} + +static const LogString *range_to_string(Range range) { + switch (range) { + case Range::RANGE_2G: + return LOG_STR("±2g"); + case Range::RANGE_4G: + return LOG_STR("±4g"); + case Range::RANGE_8G: + return LOG_STR("±8g"); + case Range::RANGE_16G: + return LOG_STR("±16g"); + } + return LOG_STR("Unknown"); +} + +static const LogString *data_rate_to_string(DataRate rate) { + switch (rate) { + case DataRate::RATE_1_6HZ: + return LOG_STR("1.6 Hz"); + case DataRate::RATE_12_5HZ: + return LOG_STR("12.5 Hz"); + case DataRate::RATE_25HZ: + return LOG_STR("25 Hz"); + case DataRate::RATE_50HZ: + return LOG_STR("50 Hz"); + case DataRate::RATE_100HZ: + return LOG_STR("100 Hz"); + case DataRate::RATE_200HZ: + return LOG_STR("200 Hz"); + case DataRate::RATE_400HZ: + return LOG_STR("400 Hz"); + case DataRate::RATE_800HZ: + return LOG_STR("800 Hz"); + case DataRate::RATE_1600HZ: + return LOG_STR("1600 Hz"); + } + return LOG_STR("Unknown"); +} + +static const LogString *filter_bandwidth_to_string(FilterBandwidth bw) { + switch (bw) { + case FilterBandwidth::BW_ODR_DIV_2: + return LOG_STR("ODR/2"); + case FilterBandwidth::BW_ODR_DIV_4: + return LOG_STR("ODR/4"); + case FilterBandwidth::BW_ODR_DIV_10: + return LOG_STR("ODR/10"); + case FilterBandwidth::BW_ODR_DIV_20: + return LOG_STR("ODR/20"); + } + return LOG_STR("Unknown"); +} + +void LIS2DW12Component::setup() { + uint8_t who_am_i{0}; + if (!this->read_byte(static_cast(RegisterMap::WHO_AM_I), &who_am_i) || who_am_i != LIS2DW12_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "WHO_AM_I check failed. Got 0x%02X, expected 0x%02X", who_am_i, LIS2DW12_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + // Soft reset + if (!this->write_reg_(RegisterMap::CTRL2, 0x40)) { + this->mark_failed(LOG_STR("Soft reset failed")); + return; + } + delay(1); // NOLINT - wait for reset to complete + + uint8_t ctrl2{0}; + if (!this->read_byte(static_cast(RegisterMap::CTRL2), &ctrl2) || (ctrl2 & 0x40)) { + this->mark_failed(LOG_STR("Soft reset did not complete")); + return; + } + + if (!this->configure_registers_()) { + this->mark_failed(LOG_STR("Register configuration failed")); + return; + } + + // 0.244 mg/LSB at ±2g (14-bit), doubling per range step; 12-bit LP1 is 4x coarser + const bool lp1 = this->power_mode_ == PowerMode::LOW_POWER_1; + const uint8_t range_idx = static_cast(this->range_); + const float mg = 0.244f * (1u << range_idx) * (lp1 ? 4 : 1); + this->scale_ = mg * 0.001f * GRAVITY_EARTH; + this->shift_ = lp1 ? 4 : 2; + + bool events_used = !this->tap_callback_.empty() || !this->double_tap_callback_.empty() || + !this->freefall_callback_.empty() || !this->active_callback_.empty() || + !this->orientation_callback_.empty(); +#ifdef USE_BINARY_SENSOR + for (auto *sensor : this->event_binary_sensors_) { + if (sensor != nullptr) { + sensor->publish_initial_state(false); + events_used = true; + } + } +#endif +#ifdef USE_TEXT_SENSOR + RegSixdSrc sixd; + if (this->orientation_text_sensor_ != nullptr && + this->read_byte(static_cast(RegisterMap::SIXD_SRC), &sixd.raw)) { + this->publish_orientation_(sixd); + events_used = true; + } +#endif + if (!events_used) { + this->disable_loop(); // nothing consumes the events, so skip polling the chip + } +} + +bool LIS2DW12Component::configure_registers_() { + // CTRL1: ODR[7:4], MODE[3:2] (00=low power, 01=high performance), LP_MODE[1:0] (LP1..LP4) + const uint8_t odr_bits = static_cast(this->data_rate_) << 4; + uint8_t ctrl1; + if (this->power_mode_ == PowerMode::HIGH_PERF) { + ctrl1 = odr_bits | 0x04; + } else { + ctrl1 = odr_bits | ((static_cast(this->power_mode_) - 1) & 0x03); + } + + // CTRL6: BW_FILT[7:6], FS[5:4], LOW_NOISE[2] + uint8_t ctrl6 = (static_cast(this->filter_bandwidth_) << 6) | (static_cast(this->range_) << 4); + if (this->low_noise_) { + ctrl6 |= 0x04; + } + + return this->write_reg_(RegisterMap::CTRL2, 0x0C) && // BDU, IF_ADD_INC + this->write_reg_(RegisterMap::CTRL1, ctrl1) && // ODR and power mode + this->write_reg_(RegisterMap::CTRL6, ctrl6) && // bandwidth, range, low noise + this->write_reg_(RegisterMap::TAP_THS_X, 0x29) && // 6D_THS=70 degrees, TAP_THSX=9 + this->write_reg_(RegisterMap::TAP_THS_Y, 0x09) && // TAP_THSY=9 + this->write_reg_(RegisterMap::TAP_THS_Z, 0xE9) && // TAP_THSZ=9, tap on X/Y/Z + this->write_reg_(RegisterMap::INT_DUR, 0x76) && // LATENCY=7, QUIET=1, SHOCK=2 + this->write_reg_(RegisterMap::WAKE_UP_THS, 0x82) && // double tap enabled, WU_THS=2 + this->write_reg_(RegisterMap::WAKE_UP_DUR, 0x00) && // no wake/sleep durations + this->write_reg_(RegisterMap::FREE_FALL, 0x33) && // FF_DUR=6, FF_THS=312 mg + this->write_reg_(RegisterMap::CTRL4_INT1, 0xF8) && // 6D, single tap, wake-up, free-fall, double tap to INT1 + this->write_reg_(RegisterMap::CTRL3, 0x10) && // LIR: latched interrupts + this->write_reg_(RegisterMap::CTRL7, 0x20); // INTERRUPTS_ENABLE +} + +void LIS2DW12Component::dump_config() { + ESP_LOGCONFIG(TAG, "LIS2DW12:"); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + ESP_LOGCONFIG(TAG, + " Power Mode: %s\n" + " Data Rate: %s\n" + " Range: %s\n" + " Filter Bandwidth: %s\n" + " Low Noise: %s\n" + " Offsets: {%.3f m/s², %.3f m/s², %.3f m/s²}\n" + " Transform: {mirror_x=%s, mirror_y=%s, mirror_z=%s, swap_xy=%s}", + LOG_STR_ARG(power_mode_to_string(this->power_mode_)), + LOG_STR_ARG(data_rate_to_string(this->data_rate_)), LOG_STR_ARG(range_to_string(this->range_)), + LOG_STR_ARG(filter_bandwidth_to_string(this->filter_bandwidth_)), YESNO(this->low_noise_), + this->offset_x_, this->offset_y_, this->offset_z_, YESNO(this->mirror_x_), YESNO(this->mirror_y_), + YESNO(this->mirror_z_), YESNO(this->swap_xy_)); + LOG_UPDATE_INTERVAL(this); + +#ifdef USE_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Tap", this->event_binary_sensors_[EVENT_TAP]); + LOG_BINARY_SENSOR(" ", "Double Tap", this->event_binary_sensors_[EVENT_DOUBLE_TAP]); + LOG_BINARY_SENSOR(" ", "Freefall", this->event_binary_sensors_[EVENT_FREEFALL]); + LOG_BINARY_SENSOR(" ", "Active", this->event_binary_sensors_[EVENT_ACTIVE]); +#endif + +#ifdef USE_SENSOR + LOG_SENSOR(" ", "Acceleration X", this->acceleration_x_sensor_); + LOG_SENSOR(" ", "Acceleration Y", this->acceleration_y_sensor_); + LOG_SENSOR(" ", "Acceleration Z", this->acceleration_z_sensor_); +#endif + +#ifdef USE_TEXT_SENSOR + LOG_TEXT_SENSOR(" ", "Orientation", this->orientation_text_sensor_); +#endif +} + +bool LIS2DW12Component::read_acceleration_data_(float &x, float &y, float &z) { + uint8_t accel_data[6]; + if (!this->read_bytes(static_cast(RegisterMap::OUT_X_L), accel_data, 6)) { + return false; + } + + int16_t raw_x = static_cast(encode_uint16(accel_data[1], accel_data[0])) >> this->shift_; + int16_t raw_y = static_cast(encode_uint16(accel_data[3], accel_data[2])) >> this->shift_; + int16_t raw_z = static_cast(encode_uint16(accel_data[5], accel_data[4])) >> this->shift_; + + if (this->swap_xy_) { + std::swap(raw_x, raw_y); + } + x = (this->mirror_x_ ? -raw_x : raw_x) * this->scale_ + this->offset_x_; + y = (this->mirror_y_ ? -raw_y : raw_y) * this->scale_ + this->offset_y_; + z = (this->mirror_z_ ? -raw_z : raw_z) * this->scale_ + this->offset_z_; + return true; +} + +#ifdef USE_TEXT_SENSOR +static const char *orientation_to_string(RegSixdSrc sixd) { + if (sixd.xh) + return "X Up"; + if (sixd.xl) + return "X Down"; + if (sixd.yh) + return "Y Up"; + if (sixd.yl) + return "Y Down"; + if (sixd.zh) + return "Z Up"; + if (sixd.zl) + return "Z Down"; + return "Unknown"; +} + +void LIS2DW12Component::publish_orientation_(RegSixdSrc sixd) { + const uint8_t position = sixd.raw & SIXD_POSITION_MASK; + if (this->orientation_text_sensor_ == nullptr || position == this->sixd_last_) { + return; + } + this->sixd_last_ = position; + this->orientation_text_sensor_->publish_state(orientation_to_string(sixd)); +} +#endif + +void LIS2DW12Component::handle_event_(Event event, bool active, uint32_t now, LazyCallbackManager &callback) { + const uint8_t bit = 1 << event; + bool state; + if (active) { + if (now - this->last_event_ms_[event] <= EVENT_COOLDOWN_MS) { + return; + } + this->last_event_ms_[event] = now; + callback.call(); + if (this->events_on_ & bit) { + return; + } + this->events_on_ |= bit; + state = true; + } else { + if (!(this->events_on_ & bit) || now - this->last_event_ms_[event] <= EVENT_COOLDOWN_MS) { + return; + } + this->events_on_ &= ~bit; + state = false; + } +#ifdef USE_BINARY_SENSOR + if (this->event_binary_sensors_[event] != nullptr) { + this->event_binary_sensors_[event]->publish_state(state); + } +#endif +} + +void LIS2DW12Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ms_ < POLL_INTERVAL_MS) { + return; + } + this->last_poll_ms_ = now; + + // SIXD_SRC and ALL_INT_SRC are adjacent, so one read captures both before any latch clears + uint8_t regs[2]; + if (!this->read_bytes(static_cast(RegisterMap::SIXD_SRC), regs, sizeof(regs))) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + RegSixdSrc sixd; + sixd.raw = regs[0]; + RegAllIntSrc src; + src.raw = regs[1]; + + this->handle_event_(EVENT_TAP, src.single_tap, now, this->tap_callback_); + this->handle_event_(EVENT_DOUBLE_TAP, src.double_tap, now, this->double_tap_callback_); + this->handle_event_(EVENT_FREEFALL, src.ff_ia, now, this->freefall_callback_); + this->handle_event_(EVENT_ACTIVE, src.wu_ia, now, this->active_callback_); + + if (sixd.d6d_ia) { + this->orientation_callback_.call(); +#ifdef USE_TEXT_SENSOR + this->publish_orientation_(sixd); +#endif + } +} + +void LIS2DW12Component::update() { + float x, y, z; + if (!this->read_acceleration_data_(x, y, z)) { + this->status_set_warning(); + return; + } + + ESP_LOGV(TAG, "Acceleration: {x = %+1.3f m/s², y = %+1.3f m/s², z = %+1.3f m/s²}", x, y, z); + +#ifdef USE_SENSOR + if (this->acceleration_x_sensor_ != nullptr) + this->acceleration_x_sensor_->publish_state(x); + if (this->acceleration_y_sensor_ != nullptr) + this->acceleration_y_sensor_->publish_state(y); + if (this->acceleration_z_sensor_ != nullptr) + this->acceleration_z_sensor_->publish_state(z); +#endif + + this->status_clear_warning(); +} + +void LIS2DW12Component::set_offset(float offset_x, float offset_y, float offset_z) { + this->offset_x_ = offset_x; + this->offset_y_ = offset_y; + this->offset_z_ = offset_z; +} + +void LIS2DW12Component::set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy) { + this->mirror_x_ = mirror_x; + this->mirror_y_ = mirror_y; + this->mirror_z_ = mirror_z; + this->swap_xy_ = swap_xy; +} + +} // namespace esphome::lis2dw12 diff --git a/esphome/components/lis2dw12/lis2dw12.h b/esphome/components/lis2dw12/lis2dw12.h new file mode 100644 index 0000000000..11c1e6b862 --- /dev/null +++ b/esphome/components/lis2dw12/lis2dw12.h @@ -0,0 +1,198 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#ifdef USE_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor.h" +#endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif + +namespace esphome::lis2dw12 { + +// LIS2DW12 Register Map +enum class RegisterMap : uint8_t { + WHO_AM_I = 0x0F, + CTRL1 = 0x20, + CTRL2 = 0x21, + CTRL3 = 0x22, + CTRL4_INT1 = 0x23, + CTRL6 = 0x25, + OUT_X_L = 0x28, + TAP_THS_X = 0x30, + TAP_THS_Y = 0x31, + TAP_THS_Z = 0x32, + INT_DUR = 0x33, + WAKE_UP_THS = 0x34, + WAKE_UP_DUR = 0x35, + FREE_FALL = 0x36, + SIXD_SRC = 0x3A, + ALL_INT_SRC = 0x3B, + CTRL7 = 0x3F, +}; + +enum class Range : uint8_t { + RANGE_2G = 0b00, + RANGE_4G = 0b01, + RANGE_8G = 0b10, + RANGE_16G = 0b11, +}; + +enum class PowerMode : uint8_t { + HIGH_PERF = 0, + LOW_POWER_1 = 1, + LOW_POWER_2 = 2, + LOW_POWER_3 = 3, + LOW_POWER_4 = 4, +}; + +enum class DataRate : uint8_t { + RATE_1_6HZ = 0b0001, + RATE_12_5HZ = 0b0010, + RATE_25HZ = 0b0011, + RATE_50HZ = 0b0100, + RATE_100HZ = 0b0101, + RATE_200HZ = 0b0110, + RATE_400HZ = 0b0111, + RATE_800HZ = 0b1000, + RATE_1600HZ = 0b1001, +}; + +enum class FilterBandwidth : uint8_t { + BW_ODR_DIV_2 = 0b00, + BW_ODR_DIV_4 = 0b01, + BW_ODR_DIV_10 = 0b10, + BW_ODR_DIV_20 = 0b11, +}; + +// ALL_INT_SRC register (0x3B) +union RegAllIntSrc { + struct { + bool ff_ia : 1; + bool wu_ia : 1; + bool single_tap : 1; + bool double_tap : 1; + bool d6d_ia : 1; + bool sleep_change_ia : 1; + uint8_t reserved : 2; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// SIXD_SRC register (0x3A) +union RegSixdSrc { + struct { + bool xl : 1; + bool xh : 1; + bool yl : 1; + bool yh : 1; + bool zl : 1; + bool zh : 1; + bool d6d_ia : 1; + uint8_t reserved : 1; + } __attribute__((packed)); + uint8_t raw{0}; +}; + +// Debounced events, also the bit index into events_on_ +enum Event : uint8_t { + EVENT_TAP = 0, + EVENT_DOUBLE_TAP, + EVENT_FREEFALL, + EVENT_ACTIVE, + EVENT_COUNT, +}; + +class LIS2DW12Component : public PollingComponent { + public: + void setup() override; + void dump_config() override; + void loop() override; + void update() override; + + void set_power_mode(PowerMode mode) { this->power_mode_ = mode; } + void set_low_noise(bool low_noise) { this->low_noise_ = low_noise; } + void set_output_data_rate(DataRate rate) { this->data_rate_ = rate; } + void set_range(Range range) { this->range_ = range; } + void set_filter_bandwidth(FilterBandwidth bw) { this->filter_bandwidth_ = bw; } + void set_offset(float offset_x, float offset_y, float offset_z); + void set_transform(bool mirror_x, bool mirror_y, bool mirror_z, bool swap_xy); + + bool read_byte(uint8_t a_register, uint8_t *data) { return this->read_bytes(a_register, data, 1); } + virtual bool write_byte(uint8_t a_register, uint8_t data) = 0; + virtual bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) = 0; + +#ifdef USE_BINARY_SENSOR + void set_tap_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_TAP] = s; } + void set_double_tap_binary_sensor(binary_sensor::BinarySensor *s) { + this->event_binary_sensors_[EVENT_DOUBLE_TAP] = s; + } + void set_freefall_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_FREEFALL] = s; } + void set_active_binary_sensor(binary_sensor::BinarySensor *s) { this->event_binary_sensors_[EVENT_ACTIVE] = s; } +#endif + +#ifdef USE_SENSOR + SUB_SENSOR(acceleration_x) + SUB_SENSOR(acceleration_y) + SUB_SENSOR(acceleration_z) +#endif + +#ifdef USE_TEXT_SENSOR + SUB_TEXT_SENSOR(orientation) +#endif + + template void add_on_tap_callback(F &&callback) { this->tap_callback_.add(std::forward(callback)); } + template void add_on_double_tap_callback(F &&callback) { + this->double_tap_callback_.add(std::forward(callback)); + } + template void add_on_freefall_callback(F &&callback) { + this->freefall_callback_.add(std::forward(callback)); + } + template void add_on_active_callback(F &&callback) { + this->active_callback_.add(std::forward(callback)); + } + template void add_on_orientation_callback(F &&callback) { + this->orientation_callback_.add(std::forward(callback)); + } + + protected: + bool write_reg_(RegisterMap reg, uint8_t value) { return this->write_byte(static_cast(reg), value); } + bool configure_registers_(); + bool read_acceleration_data_(float &x, float &y, float &z); + void handle_event_(Event event, bool active, uint32_t now, LazyCallbackManager &callback); +#ifdef USE_TEXT_SENSOR + void publish_orientation_(RegSixdSrc sixd); +#endif + + float offset_x_{0}, offset_y_{0}, offset_z_{0}; + float scale_{0}; // m/s² per LSB after shift_ + uint32_t last_event_ms_[EVENT_COUNT]{}; + uint32_t last_poll_ms_{0}; +#ifdef USE_BINARY_SENSOR + binary_sensor::BinarySensor *event_binary_sensors_[EVENT_COUNT]{}; +#endif + LazyCallbackManager tap_callback_; + LazyCallbackManager double_tap_callback_; + LazyCallbackManager freefall_callback_; + LazyCallbackManager active_callback_; + LazyCallbackManager orientation_callback_; + + PowerMode power_mode_{PowerMode::HIGH_PERF}; + DataRate data_rate_{DataRate::RATE_100HZ}; + Range range_{Range::RANGE_2G}; + FilterBandwidth filter_bandwidth_{FilterBandwidth::BW_ODR_DIV_2}; + bool low_noise_{false}; + bool mirror_x_{false}, mirror_y_{false}, mirror_z_{false}, swap_xy_{false}; + uint8_t shift_{2}; // data is left-justified: 14-bit >> 2, 12-bit (LP1) >> 4 + uint8_t events_on_{0}; // bit per Event: binary sensor currently published on +#ifdef USE_TEXT_SENSOR + uint8_t sixd_last_{0xFF}; // position bits only; 0xFF cannot occur, so the first read publishes +#endif +}; + +} // namespace esphome::lis2dw12 diff --git a/esphome/components/lis2dw12/sensor.py b/esphome/components/lis2dw12/sensor.py new file mode 100644 index 0000000000..9f4a4e570f --- /dev/null +++ b/esphome/components/lis2dw12/sensor.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ACCELERATION_X, + CONF_ACCELERATION_Y, + CONF_ACCELERATION_Z, + CONF_NAME, + ICON_ACCELERATION, + STATE_CLASS_MEASUREMENT, + UNIT_METER_PER_SECOND_SQUARED, +) +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +ACCELERATION_SENSORS = (CONF_ACCELERATION_X, CONF_ACCELERATION_Y, CONF_ACCELERATION_Z) + +accel_schema = cv.maybe_simple_value( + sensor.sensor_schema( + unit_of_measurement=UNIT_METER_PER_SECOND_SQUARED, + icon=ICON_ACCELERATION, + accuracy_decimals=2, + state_class=STATE_CLASS_MEASUREMENT, + ), + key=CONF_NAME, +) + + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + {cv.Optional(key): accel_schema for key in ACCELERATION_SENSORS} +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_ACCELERATION_X, hub.set_acceleration_x_sensor) + await sensors(CONF_ACCELERATION_Y, hub.set_acceleration_y_sensor) + await sensors(CONF_ACCELERATION_Z, hub.set_acceleration_z_sensor) diff --git a/esphome/components/lis2dw12/text_sensor.py b/esphome/components/lis2dw12/text_sensor.py new file mode 100644 index 0000000000..b292cc87cc --- /dev/null +++ b/esphome/components/lis2dw12/text_sensor.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_NAME, ICON_SCREEN_ROTATION +from esphome.types import ConfigType + +from . import CONF_LIS2DW12_ID, LIS2DW12_SENSOR_SCHEMA + +CODEOWNERS = ["@latonita"] +DEPENDENCIES = ["lis2dw12"] + +CONF_ORIENTATION = "orientation" + +CONFIG_SCHEMA = LIS2DW12_SENSOR_SCHEMA.extend( + { + cv.Optional(CONF_ORIENTATION): cv.maybe_simple_value( + text_sensor.text_sensor_schema(icon=ICON_SCREEN_ROTATION), + key=CONF_NAME, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_LIS2DW12_ID]) + text_sensors = text_sensor.sub_text_sensors(config) + await text_sensors(CONF_ORIENTATION, hub.set_orientation_text_sensor) diff --git a/esphome/components/lis2dw12_i2c/__init__.py b/esphome/components/lis2dw12_i2c/__init__.py new file mode 100644 index 0000000000..39cbd46a8d --- /dev/null +++ b/esphome/components/lis2dw12_i2c/__init__.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.lis2dw12 import ( + CONFIG_SCHEMA_BASE, + LIS2DW12Component, + to_code_base, + validate_data_rate, +) +import esphome.config_validation as cv +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +AUTO_LOAD = ["lis2dw12"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lis2dw12_i2c" +MULTI_CONF = True + +lis2dw12_i2c_ns = cg.esphome_ns.namespace("lis2dw12_i2c") +LIS2DW12I2CComponent = lis2dw12_i2c_ns.class_( + "LIS2DW12I2CComponent", LIS2DW12Component, i2c.I2CDevice +) + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend( + { + cv.GenerateID(): cv.declare_id(LIS2DW12I2CComponent), + } + ).extend(i2c.i2c_device_schema(0x19)), + validate_data_rate, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp new file mode 100644 index 0000000000..3956f0a42d --- /dev/null +++ b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.cpp @@ -0,0 +1,13 @@ +#include "lis2dw12_i2c.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12_i2c { + +ESPHOME_LOG_TAG(TAG, "lis2dw12_i2c"); + +void LIS2DW12I2CComponent::dump_config() { + LOG_I2C_DEVICE(this); + lis2dw12::LIS2DW12Component::dump_config(); +} + +} // namespace esphome::lis2dw12_i2c diff --git a/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h new file mode 100644 index 0000000000..aa6069e434 --- /dev/null +++ b/esphome/components/lis2dw12_i2c/lis2dw12_i2c.h @@ -0,0 +1,17 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/lis2dw12/lis2dw12.h" + +namespace esphome::lis2dw12_i2c { + +class LIS2DW12I2CComponent : public lis2dw12::LIS2DW12Component, public i2c::I2CDevice { + public: + bool write_byte(uint8_t a_register, uint8_t data) override { return i2c::I2CDevice::write_byte(a_register, data); } + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override { + return i2c::I2CDevice::read_bytes(a_register, data, len); + } + void dump_config() override; +}; + +} // namespace esphome::lis2dw12_i2c diff --git a/esphome/components/lis2dw12_spi/__init__.py b/esphome/components/lis2dw12_spi/__init__.py new file mode 100644 index 0000000000..1b0815d691 --- /dev/null +++ b/esphome/components/lis2dw12_spi/__init__.py @@ -0,0 +1,37 @@ +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.lis2dw12 import ( + CONFIG_SCHEMA_BASE, + LIS2DW12Component, + to_code_base, + validate_data_rate, +) +import esphome.config_validation as cv +from esphome.types import ConfigType + +CODEOWNERS = ["@latonita"] +AUTO_LOAD = ["lis2dw12"] +DEPENDENCIES = ["spi"] +DOMAIN = "lis2dw12_spi" +MULTI_CONF = True + +lis2dw12_spi_ns = cg.esphome_ns.namespace("lis2dw12_spi") +LIS2DW12SPIComponent = lis2dw12_spi_ns.class_( + "LIS2DW12SPIComponent", + LIS2DW12Component, + spi.SPIDevice, +) + +CONFIG_SCHEMA = cv.All( + CONFIG_SCHEMA_BASE.extend( + { + cv.GenerateID(): cv.declare_id(LIS2DW12SPIComponent), + } + ).extend(spi.spi_device_schema(cs_pin_required=True)), + validate_data_rate, +) + + +async def to_code(config: ConfigType) -> None: + var = await to_code_base(config) + await spi.register_spi_device(var, config) diff --git a/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp b/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp new file mode 100644 index 0000000000..0ca013ef6c --- /dev/null +++ b/esphome/components/lis2dw12_spi/lis2dw12_spi.cpp @@ -0,0 +1,34 @@ +#include "lis2dw12_spi.h" +#include "esphome/core/log.h" + +namespace esphome::lis2dw12_spi { + +ESPHOME_LOG_TAG(TAG, "lis2dw12_spi"); + +void LIS2DW12SPIComponent::setup() { + this->spi_setup(); + lis2dw12::LIS2DW12Component::setup(); +} + +void LIS2DW12SPIComponent::dump_config() { + LOG_SPI_DEVICE(this); + lis2dw12::LIS2DW12Component::dump_config(); +} + +bool LIS2DW12SPIComponent::write_byte(uint8_t a_register, uint8_t data) { + this->enable(); + this->transfer_byte(a_register & 0x3F); + this->transfer_byte(data); + this->disable(); + return true; +} + +bool LIS2DW12SPIComponent::read_bytes(uint8_t a_register, uint8_t *data, size_t len) { + this->enable(); + this->transfer_byte(a_register | 0x80); + this->read_array(data, len); + this->disable(); + return true; +} + +} // namespace esphome::lis2dw12_spi diff --git a/esphome/components/lis2dw12_spi/lis2dw12_spi.h b/esphome/components/lis2dw12_spi/lis2dw12_spi.h new file mode 100644 index 0000000000..0b9b3a2e54 --- /dev/null +++ b/esphome/components/lis2dw12_spi/lis2dw12_spi.h @@ -0,0 +1,18 @@ +#pragma once + +#include "esphome/components/lis2dw12/lis2dw12.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::lis2dw12_spi { + +class LIS2DW12SPIComponent : public lis2dw12::LIS2DW12Component, + public spi::SPIDevice { + public: + void setup() override; + void dump_config() override; + bool write_byte(uint8_t a_register, uint8_t data) override; + bool read_bytes(uint8_t a_register, uint8_t *data, size_t len) override; +}; + +} // namespace esphome::lis2dw12_spi diff --git a/esphome/components/lis3dh/__init__.py b/esphome/components/lis3dh/__init__.py new file mode 100644 index 0000000000..99c4ee9187 --- /dev/null +++ b/esphome/components/lis3dh/__init__.py @@ -0,0 +1,15 @@ +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.motion import MotionComponent + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c", "motion"] +DOMAIN = "lis3dh" + +CONF_LIS3DH_ID = "lis3dh_id" + +# C++ namespace / class +lis3dh_ns = cg.esphome_ns.namespace("lis3dh") +LIS3DHComponent = lis3dh_ns.class_("LIS3DHComponent", MotionComponent, i2c.I2CDevice) + +CONFIG_SCHEMA = {} diff --git a/esphome/components/lis3dh/lis3dh.cpp b/esphome/components/lis3dh/lis3dh.cpp new file mode 100644 index 0000000000..22ca7ae56b --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.cpp @@ -0,0 +1,195 @@ +#include "lis3dh.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lis3dh { + +ESPHOME_LOG_TAG(TAG, "lis3dh"); + +// g per LSB of the left-justified 16-bit sample, indexed by range. The same in every +// operating mode, since the chip leaves the unused low bits at zero. +static const float SCALE_G[] PROGMEM = {0.0625e-3f, 0.125e-3f, 0.25e-3f, 0.75e-3f}; + +static const LogString *mode_to_str(LIS3DHOperatingMode mode) { + switch (mode) { + case LIS3DH_MODE_LOW_POWER: + return LOG_STR("low power (8-bit)"); + case LIS3DH_MODE_NORMAL: + return LOG_STR("normal (10-bit)"); + default: + return LOG_STR("high resolution (12-bit)"); + } +} + +// Output data rate in Hz, indexed by the ODR field minus one. +static const uint16_t RATE_HZ[] PROGMEM = {1, 10, 25, 50, 100, 200, 400, 1620, 1344}; + +void LIS3DHComponent::setup() { + MotionComponent::setup(); + + uint8_t who_am_i = 0; + if (!this->read_byte(LIS3DH_REG_WHO_AM_I, &who_am_i)) { + ESP_LOGE(TAG, "Failed to read chip ID - check wiring / address"); + this->mark_failed(); + return; + } + if (who_am_i != LIS3DH_WHO_AM_I_VALUE) { + ESP_LOGE(TAG, "Wrong chip ID: 0x%02X (expected 0x%02X)", who_am_i, LIS3DH_WHO_AM_I_VALUE); + this->mark_failed(); + return; + } + + if (!this->setup_accelerometer_() || !this->setup_temperature_() || !this->setup_interrupt_()) { + ESP_LOGE(TAG, "Failed to configure sensor"); + this->mark_failed(); + return; + } +} + +bool LIS3DHComponent::setup_accelerometer_() { + // CTRL_REG1: output data rate, low-power flag and axis enable. + uint8_t ctrl_reg1 = ((uint8_t) this->data_rate_ << 4) | LIS3DH_CTRL_REG1_AXES_EN; + if (this->operating_mode_ == LIS3DH_MODE_LOW_POWER) + ctrl_reg1 |= LIS3DH_CTRL_REG1_LPEN; + if (!this->write_byte(LIS3DH_REG_CTRL_REG1, ctrl_reg1)) + return false; + + // CTRL_REG4: block data update, full-scale range and high-resolution flag. + uint8_t ctrl_reg4 = LIS3DH_CTRL_REG4_BDU | ((uint8_t) this->range_ << 4); + if (this->operating_mode_ == LIS3DH_MODE_HIGH_RESOLUTION) + ctrl_reg4 |= LIS3DH_CTRL_REG4_HR; + return this->write_byte(LIS3DH_REG_CTRL_REG4, ctrl_reg4); +} + +bool LIS3DHComponent::setup_temperature_() { + // Only power the ADC / temperature sensor when a temperature sensor is used. + uint8_t temp_cfg = + this->temperature_callback_.empty() ? 0x00 : (uint8_t) (LIS3DH_TEMP_CFG_ADC_EN | LIS3DH_TEMP_CFG_TEMP_EN); + return this->write_byte(LIS3DH_REG_TEMP_CFG, temp_cfg); +} + +bool LIS3DHComponent::setup_interrupt_() { + // Runs even with no interrupt configured: the chip usually stays powered across resets, + // so this drops whatever the previous firmware left in the interrupt registers. + // High-pass filter the interrupt (IA1) input so the ~1 g of gravity is removed + // from the threshold comparison. The output data registers stay unfiltered + // (FDS = 0), so acceleration / pitch / roll still report the full gravity vector. + uint8_t ctrl_reg2 = this->interrupt_high_pass_ ? LIS3DH_CTRL_REG2_HP_IA1 : 0x00; + if (!this->write_byte(LIS3DH_REG_CTRL_REG2, ctrl_reg2)) + return false; + + if (!this->write_byte(LIS3DH_REG_INT1_CFG, this->interrupt_axes_cfg_) || + !this->write_byte(LIS3DH_REG_INT1_THS, this->interrupt_threshold_) || + !this->write_byte(LIS3DH_REG_INT1_DURATION, this->interrupt_duration_)) + return false; + + // Latch the request so a brief movement keeps the pad asserted until INT1_SRC is read. + uint8_t ctrl_reg5 = this->interrupt_latched_ ? LIS3DH_CTRL_REG5_LIR_INT1 : 0; + if (!this->write_byte(LIS3DH_REG_CTRL_REG5, ctrl_reg5)) + return false; + + // Route the IA1 engine to the chosen physical pad and set the pad polarity. + uint8_t ctrl_reg3 = 0; + uint8_t ctrl_reg6 = this->interrupt_active_high_ ? 0 : LIS3DH_CTRL_REG6_INT_POLARITY; + if (this->interrupt_axes_cfg_ != 0) { + if (this->interrupt_pin_ == LIS3DH_INT_PIN_INT1) { + ctrl_reg3 |= LIS3DH_CTRL_REG3_I1_IA1; + } else { + ctrl_reg6 |= LIS3DH_CTRL_REG6_I2_IA1; + } + } + if (!this->write_byte(LIS3DH_REG_CTRL_REG3, ctrl_reg3) || !this->write_byte(LIS3DH_REG_CTRL_REG6, ctrl_reg6)) + return false; + if (this->interrupt_axes_cfg_ == 0) + return true; + + // With the high-pass filter enabled, reading REFERENCE captures the current + // acceleration as the filter's zero point (removing the standing gravity bias). + if (this->interrupt_high_pass_) { + uint8_t reference = 0; + this->read_byte(LIS3DH_REG_REFERENCE, &reference); + } + + // Clear any latched request left over from the motion that woke the device. + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + return true; +} + +void LIS3DHComponent::dump_config() { + ESP_LOGCONFIG(TAG, "LIS3DH:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return; + } + + ESP_LOGCONFIG(TAG, + " Range: ±%ug\n" + " Operating mode: %s\n" + " Data rate: %uHz", + 2u << this->range_, LOG_STR_ARG(mode_to_str(this->operating_mode_)), + (unsigned) progmem_read_uint16(&RATE_HZ[this->data_rate_ - 1])); + if (this->interrupt_axes_cfg_ != 0) { + ESP_LOGCONFIG(TAG, + " Motion interrupt: %s (%s%s%s)\n" + " Threshold: %u steps\n" + " Duration: %u samples", + this->interrupt_pin_ == LIS3DH_INT_PIN_INT1 ? LOG_STR_LITERAL("INT1") : LOG_STR_LITERAL("INT2"), + this->interrupt_active_high_ ? LOG_STR_LITERAL("active high") : LOG_STR_LITERAL("active low"), + this->interrupt_high_pass_ ? LOG_STR_LITERAL(", high-pass") : LOG_STR_LITERAL(""), + this->interrupt_latched_ ? LOG_STR_LITERAL(", latched") : LOG_STR_LITERAL(""), + this->interrupt_threshold_, this->interrupt_duration_); + } + MotionComponent::dump_config(); +} + +void LIS3DHComponent::on_shutdown() { + // Clear a latched request from motion seen while awake, so it does not wake the device straight away. + if (this->interrupt_latched_ && this->interrupt_axes_cfg_ != 0 && !this->is_failed()) { + uint8_t src = 0; + this->read_byte(LIS3DH_REG_INT1_SRC, &src); + } +} + +bool LIS3DHComponent::update_data(motion::MotionData &data) { + if (this->is_failed()) + return false; + + uint8_t raw[6]; + if (!this->read_bytes(LIS3DH_REG_OUT_X_L | LIS3DH_AUTO_INCREMENT, raw, 6)) { + this->status_set_warning(LOG_STR("Failed to read acceleration data")); + return false; + } + + float scale; + progmem_memcpy(&scale, &SCALE_G[this->range_], sizeof(scale)); + + // Data is little-endian and left-justified in a signed 16-bit register. + int16_t raw_x = (int16_t) encode_uint16(raw[1], raw[0]); + int16_t raw_y = (int16_t) encode_uint16(raw[3], raw[2]); + int16_t raw_z = (int16_t) encode_uint16(raw[5], raw[4]); + ESP_LOGV(TAG, "Read raw accel data: %d, %d, %d", raw_x, raw_y, raw_z); + data.acceleration[motion::X_AXIS] = raw_x * scale; + data.acceleration[motion::Y_AXIS] = raw_y * scale; + data.acceleration[motion::Z_AXIS] = raw_z * scale; + + if (this->temperature_callback_.empty() || this->publish_temperature_()) + this->status_clear_warning(); + return true; +} + +bool LIS3DHComponent::publish_temperature_() { + uint8_t raw[2]; + if (!this->read_bytes(LIS3DH_REG_OUT_ADC3_L | LIS3DH_AUTO_INCREMENT, raw, 2)) { + this->status_set_warning(LOG_STR("Failed to read temperature data")); + return false; + } + // Signed, left-justified, relative value with 1 °C per count of the high byte; the + // low byte carries the fractional bits in the modes that have them. + float temperature = (int16_t) encode_uint16(raw[1], raw[0]) / 256.0f + LIS3DH_TEMP_REFERENCE_C; + this->temperature_callback_.call(temperature); + return true; +} + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/lis3dh.h b/esphome/components/lis3dh/lis3dh.h new file mode 100644 index 0000000000..15c19933c7 --- /dev/null +++ b/esphome/components/lis3dh/lis3dh.h @@ -0,0 +1,135 @@ +#pragma once + +#include "esphome/components/motion/motion_component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::lis3dh { + +// Register map (see ST datasheet DocID18198) +static constexpr uint8_t LIS3DH_REG_OUT_ADC3_L = 0x0C; // temperature (ADC3) low byte +static constexpr uint8_t LIS3DH_REG_WHO_AM_I = 0x0F; +static constexpr uint8_t LIS3DH_REG_TEMP_CFG = 0x1F; +static constexpr uint8_t LIS3DH_REG_CTRL_REG1 = 0x20; // ODR / low-power / axis enable +static constexpr uint8_t LIS3DH_REG_CTRL_REG2 = 0x21; // high-pass filter +static constexpr uint8_t LIS3DH_REG_CTRL_REG3 = 0x22; // INT1 pad routing +static constexpr uint8_t LIS3DH_REG_CTRL_REG4 = 0x23; // BDU / full-scale / high-resolution +static constexpr uint8_t LIS3DH_REG_CTRL_REG5 = 0x24; // interrupt latching +static constexpr uint8_t LIS3DH_REG_CTRL_REG6 = 0x25; // INT2 pad routing / polarity +static constexpr uint8_t LIS3DH_REG_REFERENCE = 0x26; // high-pass filter reference +static constexpr uint8_t LIS3DH_REG_OUT_X_L = 0x28; // acceleration data block (auto-increment) +static constexpr uint8_t LIS3DH_REG_INT1_CFG = 0x30; +static constexpr uint8_t LIS3DH_REG_INT1_SRC = 0x31; +static constexpr uint8_t LIS3DH_REG_INT1_THS = 0x32; +static constexpr uint8_t LIS3DH_REG_INT1_DURATION = 0x33; + +static constexpr uint8_t LIS3DH_WHO_AM_I_VALUE = 0x33; + +// Sub-address auto-increment bit: OR into the register for multi-byte reads. +static constexpr uint8_t LIS3DH_AUTO_INCREMENT = 0x80; + +// CTRL_REG1 +static constexpr uint8_t LIS3DH_CTRL_REG1_AXES_EN = 0x07; // Xen | Yen | Zen +static constexpr uint8_t LIS3DH_CTRL_REG1_LPEN = 0x08; // low-power enable +// CTRL_REG2 +static constexpr uint8_t LIS3DH_CTRL_REG2_HP_IA1 = 0x01; // high-pass filter the IA1 interrupt +// CTRL_REG4 +static constexpr uint8_t LIS3DH_CTRL_REG4_HR = 0x08; // high-resolution enable +static constexpr uint8_t LIS3DH_CTRL_REG4_BDU = 0x80; // block data update +// CTRL_REG5 +static constexpr uint8_t LIS3DH_CTRL_REG5_LIR_INT1 = 0x08; // latch IA1 interrupt +// CTRL_REG3 / CTRL_REG6 interrupt routing +static constexpr uint8_t LIS3DH_CTRL_REG3_I1_IA1 = 0x40; // route IA1 to INT1 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_I2_IA1 = 0x40; // route IA1 to INT2 pad +static constexpr uint8_t LIS3DH_CTRL_REG6_INT_POLARITY = 0x02; // 1 = active low +// TEMP_CFG_REG +static constexpr uint8_t LIS3DH_TEMP_CFG_ADC_EN = 0x80; +static constexpr uint8_t LIS3DH_TEMP_CFG_TEMP_EN = 0x40; + +// The temperature sensor is uncalibrated and reports relative changes only, +// so this fixed reference is just a rough starting point for the °C output. +static constexpr float LIS3DH_TEMP_REFERENCE_C = 25.0f; + +// Full-scale range: value is the index into the scale tables and the +// FS field of CTRL_REG4 (shifted left by 4). +enum LIS3DHRange : uint8_t { + LIS3DH_RANGE_2G = 0, + LIS3DH_RANGE_4G = 1, + LIS3DH_RANGE_8G = 2, + LIS3DH_RANGE_16G = 3, +}; + +// Output data rate: value is the ODR field of CTRL_REG1 (bits 7:4). +enum LIS3DHDataRate : uint8_t { + LIS3DH_DATA_RATE_1HZ = 0x1, + LIS3DH_DATA_RATE_10HZ = 0x2, + LIS3DH_DATA_RATE_25HZ = 0x3, + LIS3DH_DATA_RATE_50HZ = 0x4, + LIS3DH_DATA_RATE_100HZ = 0x5, + LIS3DH_DATA_RATE_200HZ = 0x6, + LIS3DH_DATA_RATE_400HZ = 0x7, + LIS3DH_DATA_RATE_1620HZ = 0x8, // low-power mode only + LIS3DH_DATA_RATE_1344HZ = 0x9, // 1344 Hz normal/HR, 5376 Hz low-power +}; + +// Operating mode: selects the resolution (and thus power usage). +enum LIS3DHOperatingMode : uint8_t { + LIS3DH_MODE_LOW_POWER = 0, // 8-bit + LIS3DH_MODE_NORMAL = 1, // 10-bit + LIS3DH_MODE_HIGH_RESOLUTION = 2, // 12-bit +}; + +// Physical interrupt pad the motion interrupt is routed to. +enum LIS3DHInterruptPin : uint8_t { + LIS3DH_INT_PIN_INT1 = 0, + LIS3DH_INT_PIN_INT2 = 1, +}; + +class LIS3DHComponent : public motion::MotionComponent, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + void on_shutdown() override; + + void set_range(LIS3DHRange range) { this->range_ = range; } + void set_data_rate(LIS3DHDataRate data_rate) { this->data_rate_ = data_rate; } + void set_operating_mode(LIS3DHOperatingMode mode) { this->operating_mode_ = mode; } + // threshold is the INT1_THS value and axes_cfg the INT1_CFG mask, both computed in Python. + void set_interrupt(LIS3DHInterruptPin pin, uint8_t threshold, uint8_t duration, uint8_t axes_cfg, bool latched, + bool active_high, bool high_pass) { + this->interrupt_pin_ = pin; + this->interrupt_threshold_ = threshold; + this->interrupt_duration_ = duration; + this->interrupt_axes_cfg_ = axes_cfg; + this->interrupt_latched_ = latched; + this->interrupt_active_high_ = active_high; + this->interrupt_high_pass_ = high_pass; + } + + template void add_temperature_listener(F &&cb) { this->temperature_callback_.add(std::forward(cb)); } + + protected: + bool update_data(motion::MotionData &data) override; + bool setup_accelerometer_(); + bool setup_temperature_(); + bool setup_interrupt_(); + bool publish_temperature_(); + + LIS3DHRange range_{LIS3DH_RANGE_2G}; + LIS3DHDataRate data_rate_{LIS3DH_DATA_RATE_100HZ}; + LIS3DHOperatingMode operating_mode_{LIS3DH_MODE_HIGH_RESOLUTION}; + + // Motion (activity) interrupt configuration; a zero axis mask means disabled. + LIS3DHInterruptPin interrupt_pin_{LIS3DH_INT_PIN_INT1}; + uint8_t interrupt_threshold_{0}; + uint8_t interrupt_duration_{0}; + uint8_t interrupt_axes_cfg_{0}; + bool interrupt_latched_{false}; + bool interrupt_active_high_{true}; + bool interrupt_high_pass_{false}; + + LazyCallbackManager temperature_callback_{}; +}; + +} // namespace esphome::lis3dh diff --git a/esphome/components/lis3dh/motion.py b/esphome/components/lis3dh/motion.py new file mode 100644 index 0000000000..ea4b4d3b81 --- /dev/null +++ b/esphome/components/lis3dh/motion.py @@ -0,0 +1,214 @@ +import logging +import math + +import esphome.codegen as cg +from esphome.components import i2c +from esphome.components.const import CONF_ACCELEROMETER_ODR, CONF_ACCELEROMETER_RANGE +from esphome.components.motion import AXES, motion_schema, new_motion_component +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_INTERRUPT, CONF_PIN, CONF_THRESHOLD +from esphome.types import ConfigType + +from . import LIS3DHComponent, lis3dh_ns + +_LOGGER = logging.getLogger(__name__) + +CONF_OPERATING_MODE = "operating_mode" +CONF_AXES = "axes" +CONF_LATCHED = "latched" +CONF_ACTIVE_HIGH = "active_high" +CONF_HIGH_PASS_FILTER = "high_pass_filter" + +# Enum values must match the C++ enums. +LIS3DHRange = lis3dh_ns.enum("LIS3DHRange") +RANGE_OPTIONS = { + "2G": LIS3DHRange.LIS3DH_RANGE_2G, + "4G": LIS3DHRange.LIS3DH_RANGE_4G, + "8G": LIS3DHRange.LIS3DH_RANGE_8G, + "16G": LIS3DHRange.LIS3DH_RANGE_16G, +} + +LIS3DHDataRate = lis3dh_ns.enum("LIS3DHDataRate") +DATA_RATE_OPTIONS = { + "1HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1HZ, + "10HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_10HZ, + "25HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_25HZ, + "50HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_50HZ, + "100HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_100HZ, + "200HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_200HZ, + "400HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_400HZ, + # 1620HZ is only available in low-power mode. + "1620HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1620HZ, + # 1344HZ in normal / high-resolution mode; 5376HZ in low-power mode. + "1344HZ": LIS3DHDataRate.LIS3DH_DATA_RATE_1344HZ, +} + +# Samples per second for each data rate key; INT1_DURATION counts samples. +ODR_HZ = { + "1HZ": 1, + "10HZ": 10, + "25HZ": 25, + "50HZ": 50, + "100HZ": 100, + "200HZ": 200, + "400HZ": 400, + "1620HZ": 1620, + "1344HZ": 1344, +} +DURATION_MAX_REG = 0x7F + +LIS3DHOperatingMode = lis3dh_ns.enum("LIS3DHOperatingMode") +OPERATING_MODE_OPTIONS = { + "LOW_POWER": LIS3DHOperatingMode.LIS3DH_MODE_LOW_POWER, + "NORMAL": LIS3DHOperatingMode.LIS3DH_MODE_NORMAL, + "HIGH_RESOLUTION": LIS3DHOperatingMode.LIS3DH_MODE_HIGH_RESOLUTION, +} + +LIS3DHInterruptPin = lis3dh_ns.enum("LIS3DHInterruptPin") +INTERRUPT_PIN_OPTIONS = { + "INT1": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT1, + "INT2": LIS3DHInterruptPin.LIS3DH_INT_PIN_INT2, +} + +# INT1_CFG high-event bit per axis. +INT_CFG_HIGH_EVENT = {"x": 0x02, "y": 0x08, "z": 0x20} + +# Motion interrupt threshold LSB in mg, per range. INT1_THS holds seven bits. +THRESHOLD_LSB_MG = {"2G": 16, "4G": 32, "8G": 62, "16G": 186} +THRESHOLD_MAX_REG = 0x7F + + +def _threshold_register(threshold_g: float, range_key: str) -> int: + """Return INT1_THS for a threshold in g: nearest step, at least 1 so the pad is not held asserted.""" + return max(math.floor(threshold_g * 1000 / THRESHOLD_LSB_MG[range_key] + 0.5), 1) + + +def _duration_register(duration_ms: int, odr_key: str) -> int: + """Return INT1_DURATION for a time in ms: the nearest number of samples at the data rate.""" + return math.floor(duration_ms * ODR_HZ[odr_key] / 1000 + 0.5) + + +def _axes(value: object) -> list[str]: + value = cv.All(cv.ensure_list(cv.one_of(*AXES, lower=True)), cv.Length(min=1))( + value + ) + if len(set(value)) != len(value): + raise cv.Invalid("Each axis may be listed only once") + return value + + +# Without high_pass_filter the threshold includes gravity, so it should exceed 1g. +INTERRUPT_SCHEMA = cv.Schema( + { + cv.Optional(CONF_PIN, default="INT1"): cv.enum( + INTERRUPT_PIN_OPTIONS, upper=True + ), + cv.Optional(CONF_THRESHOLD, default="1.1g"): cv.All( + cv.float_with_unit("acceleration", "(g|G)?"), cv.positive_float + ), + cv.Optional(CONF_DURATION, default="0ms"): cv.positive_time_period_milliseconds, + cv.Optional(CONF_AXES, default=AXES): _axes, + cv.Optional(CONF_LATCHED, default=True): cv.boolean, + cv.Optional(CONF_ACTIVE_HIGH, default=True): cv.boolean, + cv.Optional(CONF_HIGH_PASS_FILTER, default=False): cv.boolean, + } +) + + +def _validate_odr_mode(config: ConfigType) -> ConfigType: + # 1620Hz exists only in low power mode; the 1344Hz code runs at 5376Hz there. + odr = config[CONF_ACCELEROMETER_ODR] + low_power = config[CONF_OPERATING_MODE] == "LOW_POWER" + if odr == "1620HZ" and not low_power: + raise cv.Invalid( + "'1620HZ' is only available with 'operating_mode: LOW_POWER'", + path=[CONF_ACCELEROMETER_ODR], + ) + if odr == "1344HZ" and low_power: + raise cv.Invalid( + "'1344HZ' is not available with 'operating_mode: LOW_POWER' " + "(that combination runs at 5376Hz)", + path=[CONF_ACCELEROMETER_ODR], + ) + return config + + +def _validate_interrupt(config: ConfigType) -> ConfigType: + if (interrupt := config.get(CONF_INTERRUPT)) is None: + return config + range_key = config[CONF_ACCELEROMETER_RANGE] + threshold = interrupt[CONF_THRESHOLD] + max_g = THRESHOLD_MAX_REG * THRESHOLD_LSB_MG[range_key] / 1000 + if threshold > max_g: + raise cv.Invalid( + f"'threshold' can be at most {max_g:g}g with 'accelerometer_range: {range_key}'", + path=[CONF_INTERRUPT, CONF_THRESHOLD], + ) + if not interrupt[CONF_HIGH_PASS_FILTER] and threshold <= 1.0: + _LOGGER.warning( + "lis3dh: a threshold of %sg or less includes gravity and may keep the interrupt " + "asserted at rest; set 'high_pass_filter: true' to compare against motion only", + threshold, + ) + odr_key = config[CONF_ACCELEROMETER_ODR] + duration_ms = interrupt[CONF_DURATION].total_milliseconds + if _duration_register(duration_ms, odr_key) > DURATION_MAX_REG: + max_ms = math.floor(DURATION_MAX_REG * 1000 / ODR_HZ[odr_key]) + raise cv.Invalid( + f"'duration' can be at most {max_ms}ms with 'accelerometer_odr: {odr_key}'", + path=[CONF_INTERRUPT, CONF_DURATION], + ) + return config + + +CONFIG_SCHEMA = cv.All( + motion_schema(LIS3DHComponent, has_accel=True, has_gyro=False) + .extend( + { + cv.Optional(CONF_ACCELEROMETER_RANGE, default="2G"): cv.enum( + RANGE_OPTIONS, upper=True + ), + cv.Optional(CONF_ACCELEROMETER_ODR, default="100HZ"): cv.enum( + DATA_RATE_OPTIONS, upper=True + ), + cv.Optional(CONF_OPERATING_MODE, default="HIGH_RESOLUTION"): cv.enum( + OPERATING_MODE_OPTIONS, upper=True + ), + cv.Optional(CONF_INTERRUPT): INTERRUPT_SCHEMA, + } + ) + .extend(i2c.i2c_device_schema(0x18)), + _validate_odr_mode, + _validate_interrupt, +) + + +async def to_code(config: ConfigType) -> None: + var = await new_motion_component(config) + await i2c.register_i2c_device(var, config) + + cg.add(var.set_range(config[CONF_ACCELEROMETER_RANGE])) + cg.add(var.set_data_rate(config[CONF_ACCELEROMETER_ODR])) + cg.add(var.set_operating_mode(config[CONF_OPERATING_MODE])) + + if (interrupt := config.get(CONF_INTERRUPT)) is not None: + threshold = _threshold_register( + interrupt[CONF_THRESHOLD], config[CONF_ACCELEROMETER_RANGE] + ) + axes_cfg = 0 + for axis in interrupt[CONF_AXES]: + axes_cfg |= INT_CFG_HIGH_EVENT[axis] + cg.add( + var.set_interrupt( + interrupt[CONF_PIN], + threshold, + _duration_register( + interrupt[CONF_DURATION].total_milliseconds, + config[CONF_ACCELEROMETER_ODR], + ), + axes_cfg, + interrupt[CONF_LATCHED], + interrupt[CONF_ACTIVE_HIGH], + interrupt[CONF_HIGH_PASS_FILTER], + ) + ) diff --git a/esphome/components/lis3dh/sensor.py b/esphome/components/lis3dh/sensor.py new file mode 100644 index 0000000000..15d19bcc0c --- /dev/null +++ b/esphome/components/lis3dh/sensor.py @@ -0,0 +1,41 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_TEMPERATURE, + CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, + ICON_THERMOMETER, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import CONF_LIS3DH_ID, LIS3DHComponent + +# The LIS3DH temperature sensor is uncalibrated and only reports relative +# changes (roughly 1 °C per count), so a single decimal of accuracy is plenty. +CONFIG_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + icon=ICON_THERMOMETER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_TEMPERATURE, +).extend( + { + cv.Optional(CONF_TYPE): cv.one_of(CONF_TEMPERATURE), + cv.GenerateID(CONF_LIS3DH_ID): cv.use_id(LIS3DHComponent), + } +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + parent = await cg.get_variable(config[CONF_LIS3DH_ID]) + data = MockObj("data") + value_lambda = await cg.process_lambda( + var.publish_state(data), + [(cg.float_, str(data))], + ) + cg.add(parent.add_temperature_listener(value_lambda)) diff --git a/esphome/components/lm75b/__init__.py b/esphome/components/lm75b/__init__.py index e69de29bb2..228b24febe 100644 --- a/esphome/components/lm75b/__init__.py +++ b/esphome/components/lm75b/__init__.py @@ -0,0 +1 @@ +DOMAIN = "lm75b" diff --git a/esphome/components/lm75b/lm75b.cpp b/esphome/components/lm75b/lm75b.cpp index 2e8b9d3cfe..56e69faa62 100644 --- a/esphome/components/lm75b/lm75b.cpp +++ b/esphome/components/lm75b/lm75b.cpp @@ -4,7 +4,7 @@ namespace esphome::lm75b { -static const char *const TAG = "lm75b"; +ESPHOME_LOG_TAG(TAG, "lm75b"); void LM75BComponent::dump_config() { ESP_LOGCONFIG(TAG, "LM75B:"); diff --git a/esphome/components/ln882h_ble/__init__.py b/esphome/components/ln882h_ble/__init__.py index aadc1f3b2f..7e382eda85 100644 --- a/esphome/components/ln882h_ble/__init__.py +++ b/esphome/components/ln882h_ble/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["ln882x"] CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "ln882h_ble" ln882h_ble_ns = cg.esphome_ns.namespace("ln882h_ble") LN882HBLE = ln882h_ble_ns.class_("LN882HBLE", cg.Component) diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 0b15bf434c..8f569086de 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -178,7 +178,7 @@ __sprintf( // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,r namespace esphome::ln882h_ble { -static const char *const TAG = "ln882h_ble"; +ESPHOME_LOG_TAG(TAG, "ln882h_ble"); // The SDK event callback is a plain C function pointer with no user argument, // so it reaches the (single) component instance through a file-static pointer. diff --git a/esphome/components/ln882h_ble_tracker/__init__.py b/esphome/components/ln882h_ble_tracker/__init__.py index 69a01ae1ac..0d2b3a5180 100644 --- a/esphome/components/ln882h_ble_tracker/__init__.py +++ b/esphome/components/ln882h_ble_tracker/__init__.py @@ -28,6 +28,7 @@ CONF_LN882H_BLE_ID = "ln882h_ble_id" DEPENDENCIES = ["ln882x"] AUTO_LOAD = ["ble_device_base", "ln882h_ble"] CODEOWNERS = ["@Bl00d-B0b"] +DOMAIN = "ln882h_ble_tracker" ble_device_base.register_hub_provider("ln882h_ble_tracker") diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index e1083e5fbe..34981560d2 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -9,7 +9,7 @@ namespace esphome::ln882h_ble_tracker { -static const char *const TAG = "ln882h_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "ln882h_ble_tracker"); static constexpr float BLE_SCAN_UNIT_MS = 0.625f; diff --git a/esphome/components/ln882x/__init__.py b/esphome/components/ln882x/__init__.py index b4e179c4a9..d23702d799 100644 --- a/esphome/components/ln882x/__init__.py +++ b/esphome/components/ln882x/__init__.py @@ -35,6 +35,7 @@ from .boards import LN882X_BOARD_PINS, LN882X_BOARDS CODEOWNERS = ["@lamauny"] AUTO_LOAD = ["libretiny"] +DOMAIN = "ln882x" IS_TARGET_PLATFORM = True COMPONENT_DATA = LibreTinyComponent( diff --git a/esphome/components/lock/__init__.py b/esphome/components/lock/__init__.py index 4ceb6206fd..cf723df9b9 100644 --- a/esphome/components/lock/__init__.py +++ b/esphome/components/lock/__init__.py @@ -12,16 +12,17 @@ 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"] +DOMAIN = "lock" IS_PLATFORM_COMPONENT = True lock_ns = cg.esphome_ns.namespace("lock") @@ -29,9 +30,6 @@ 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) LockStateForwarder = lock_ns.class_("LockStateForwarder") @@ -134,23 +132,14 @@ LOCK_ACTION_SCHEMA = maybe_simple_id( ) -@automation.register_action( - "lock.unlock", UnlockAction, LOCK_ACTION_SCHEMA, synchronous=True -) -@automation.register_action( - "lock.lock", LockAction, LOCK_ACTION_SCHEMA, synchronous=True -) -@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) +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( diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index 13153bc1df..e578a05643 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -6,36 +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_; -}; - /// 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.cpp b/esphome/components/lock/lock.cpp index 66eb692bd5..aca3a3b024 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -6,7 +6,7 @@ namespace esphome::lock { -static const char *const TAG = "lock"; +ESPHOME_LOG_TAG(TAG, "lock"); // Lock state strings indexed by LockState enum. // Index 0 is UNKNOWN (for LOCK_STATE_NONE), also used as fallback for out-of-range diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index eb1f34565a..67137214c1 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -45,7 +45,6 @@ from esphome.const import ( CONF_HARDWARE_UART, CONF_ID, CONF_LEVEL, - CONF_LOGGER, CONF_LOGS, CONF_ON_MESSAGE, CONF_TAG, @@ -235,6 +234,15 @@ def warn_ram_log_strings(config: ConfigType) -> ConfigType: 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") @@ -282,6 +290,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, @@ -355,7 +364,7 @@ CONFIG_SCHEMA = cv.All( async def to_code(config: ConfigType) -> None: baud_rate: int = config[CONF_BAUD_RATE] level = config[CONF_LEVEL] - CORE.data.setdefault(CONF_LOGGER, {})[CONF_LEVEL] = level + CORE.data.setdefault(DOMAIN, {})[CONF_LEVEL] = level tx_buffer_size = config[CONF_TX_BUFFER_SIZE] cg.add_define("ESPHOME_LOGGER_TX_BUFFER_SIZE", tx_buffer_size) # Determine task log buffer size. The buffer is a direct member of Logger @@ -504,6 +513,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 14121d3dd9..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 = { @@ -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 757ba6771e..24a6586ae2 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -8,7 +8,7 @@ namespace esphome::logger { -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); #if defined(USE_ESP32) || defined(USE_HOST) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) // Implementation for multi-threaded platforms (ESP32 with FreeRTOS, Host with pthreads, LibreTiny with FreeRTOS, @@ -144,7 +144,10 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas 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 6bdd5910f6..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 @@ -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 Triggerbaud_rate_ > 0) { @@ -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_rp2.cpp b/esphome/components/logger/logger_rp2.cpp index b949d7ee16..518a1d7b1d 100644 --- a/esphome/components/logger/logger_rp2.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -8,7 +8,7 @@ namespace esphome::logger { -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); void Logger::pre_setup() { if (this->baud_rate_ > 0) { diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index b7884b702b..95cc5d4a14 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; @@ -30,7 +37,7 @@ __attribute__((section(".noinit"))) struct { #endif } crash_buf; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static const char *const TAG = "logger"; +ESPHOME_LOG_TAG(TAG, "logger"); #ifdef USE_LOGGER_UART_SELECTION_USB_CDC void Logger::cdc_loop_() { @@ -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/select/__init__.py b/esphome/components/logger/select/__init__.py index 00f67422f3..4bd979398c 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -1,13 +1,14 @@ import esphome.codegen as cg from esphome.components import select import esphome.config_validation as cv -from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_BUG +from esphome.const import CONF_LEVEL, ENTITY_CATEGORY_CONFIG, ICON_BUG from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented from esphome.types import ConfigType from .. import ( CONF_LOGGER_ID, + DOMAIN, LOG_LEVELS, Logger, logger_ns, @@ -31,7 +32,7 @@ async def to_code(config: ConfigType) -> None: request_logger_level_listeners() parent = await cg.get_variable(config[CONF_LOGGER_ID]) levels = list(LOG_LEVELS) - index = levels.index(CORE.data[CONF_LOGGER][CONF_LEVEL]) + index = levels.index(CORE.data[DOMAIN][CONF_LEVEL]) levels = levels[: index + 1] var = await select.new_select(config, options=levels) await register_parented(var, parent) 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/lp5562/__init__.py b/esphome/components/lp5562/__init__.py new file mode 100644 index 0000000000..b8ace97fc9 --- /dev/null +++ b/esphome/components/lp5562/__init__.py @@ -0,0 +1,29 @@ +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 = ["@DennisGaida"] +DEPENDENCIES = ["i2c"] +DOMAIN = "lp5562" +MULTI_CONF = True + +lp5562_ns = cg.esphome_ns.namespace("lp5562") +LP5562Output = lp5562_ns.class_("LP5562Output", cg.Component, i2c.I2CDevice) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(LP5562Output), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x30)) +) + + +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/lp5562/lp5562_output.cpp b/esphome/components/lp5562/lp5562_output.cpp new file mode 100644 index 0000000000..356ef2c066 --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.cpp @@ -0,0 +1,72 @@ +#include "lp5562_output.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::lp5562 { + +ESPHOME_LOG_TAG(TAG, "lp5562"); + +// see table 26 in the datasheet https://web.archive.org/web/20240327023921/https://www.ti.com/lit/ds/symlink/lp5562.pdf +static constexpr uint8_t LP5562_REG_ENABLE = 0x00; +static constexpr uint8_t LP5562_REG_CONFIG = 0x08; +static constexpr uint8_t LP5562_REG_RESET = 0x0d; +static constexpr uint8_t LP5562_REG_LED_MAP = 0x70; + +static constexpr uint8_t LP5562_RESET_VALUE = 0xff; + +static constexpr uint8_t LP5562_ENABLE_CHIP_EN = 0x40; +static constexpr uint8_t LP5562_CONFIG_INT_CLK_EN = 0x01; +static constexpr uint8_t LP5562_LED_MAP_ALL_PWM_REGISTERS = 0x00; // every channel driven by its PWM register + +// PWM duty-cycle registers, indexed the same way as the LP5562_CHANNELS enum in output.py +static const uint8_t LP5562_REG_PWM[4] PROGMEM = { + 0x02, // blue + 0x03, // green + 0x04, // red + 0x0e, // white +}; + +void LP5562Output::setup() { + // clear any state left over from before a warm reboot; the chip may not ACK while resetting, so ignore the result + this->write_byte(LP5562_REG_RESET, LP5562_RESET_VALUE); + delay(1); + // power up the chip and enable the internal 32 kHz oscillator, see + // https://github.com/m5stack/M5GFX/blob/b2422cdb4735159209c2097d425c0756505b9a81/src/M5GFX.cpp#L566 + if (!this->write_byte(LP5562_REG_ENABLE, LP5562_ENABLE_CHIP_EN)) { + this->mark_failed(); + return; + } + delay(1); + // use the internal clock, and drive every channel directly from its PWM register instead of the + // internal blink/fade engines + if (!this->write_byte(LP5562_REG_CONFIG, LP5562_CONFIG_INT_CLK_EN) || + !this->write_byte(LP5562_REG_LED_MAP, LP5562_LED_MAP_ALL_PWM_REGISTERS)) { + this->mark_failed(); + } +} + +void LP5562Output::dump_config() { + ESP_LOGCONFIG(TAG, "LP5562:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, "Setting up LP5562 failed!"); + } +} + +void LP5562Output::set_channel_value_(uint8_t channel, uint8_t value) { + if (this->is_failed()) + return; + if (!this->write_byte(progmem_read_byte(&LP5562_REG_PWM[channel]), value)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); +} + +void LP5562Channel::write_state(float state) { + const uint8_t max_duty = 255; + const auto duty = static_cast(roundf(state * max_duty)); + this->parent_->set_channel_value_(this->channel_, duty); +} + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/lp5562_output.h b/esphome/components/lp5562/lp5562_output.h new file mode 100644 index 0000000000..a67a4b0658 --- /dev/null +++ b/esphome/components/lp5562/lp5562_output.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/output/float_output.h" + +namespace esphome::lp5562 { + +class LP5562Output; + +class LP5562Channel final : public output::FloatOutput, public Parented { + public: + void set_channel(uint8_t channel) { this->channel_ = channel; } + + protected: + void write_state(float state) override; + + uint8_t channel_{0}; +}; + +/// LP5562 4-channel (RGBW) I2C LED driver. +class LP5562Output final : public Component, public i2c::I2CDevice { + public: + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + protected: + friend class LP5562Channel; + + void set_channel_value_(uint8_t channel, uint8_t value); +}; + +} // namespace esphome::lp5562 diff --git a/esphome/components/lp5562/output.py b/esphome/components/lp5562/output.py new file mode 100644 index 0000000000..4866eb7b9c --- /dev/null +++ b/esphome/components/lp5562/output.py @@ -0,0 +1,35 @@ +import esphome.codegen as cg +from esphome.components import output +import esphome.config_validation as cv +from esphome.const import CONF_CHANNEL, CONF_ID +from esphome.types import ConfigType + +from . import LP5562Output, lp5562_ns + +DEPENDENCIES = ["lp5562"] + +LP5562Channel = lp5562_ns.class_("LP5562Channel", output.FloatOutput) +CONF_LP5562_ID = "lp5562_id" + +# Indices match the LP5562_REG_PWM lookup table in lp5562_output.cpp +LP5562_CHANNELS = { + "blue": 0, + "green": 1, + "red": 2, + "white": 3, +} + +CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( + { + cv.Required(CONF_ID): cv.declare_id(LP5562Channel), + cv.GenerateID(CONF_LP5562_ID): cv.use_id(LP5562Output), + cv.Required(CONF_CHANNEL): cv.enum(LP5562_CHANNELS, lower=True), + } +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_parented(var, config[CONF_LP5562_ID]) + cg.add(var.set_channel(config[CONF_CHANNEL])) + await output.register_output(var, config) diff --git a/esphome/components/lps22/__init__.py b/esphome/components/lps22/__init__.py index e69de29bb2..b9936d7ab1 100644 --- a/esphome/components/lps22/__init__.py +++ b/esphome/components/lps22/__init__.py @@ -0,0 +1 @@ +DOMAIN = "lps22" diff --git a/esphome/components/lps22/lps22.cpp b/esphome/components/lps22/lps22.cpp index 020add695c..57f801c332 100644 --- a/esphome/components/lps22/lps22.cpp +++ b/esphome/components/lps22/lps22.cpp @@ -2,7 +2,7 @@ namespace esphome::lps22 { -static constexpr const char *const TAG = "lps22"; +ESPHOME_LOG_TAG(TAG, "lps22"); static constexpr uint8_t WHO_AM_I = 0x0F; static constexpr uint8_t LPS22HB_ID = 0xB1; 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/lsm6ds/__init__.py b/esphome/components/lsm6ds/__init__.py index b1044276a1..311058249e 100644 --- a/esphome/components/lsm6ds/__init__.py +++ b/esphome/components/lsm6ds/__init__.py @@ -3,6 +3,7 @@ from esphome.components import i2c from esphome.components.motion import MotionComponent CODEOWNERS = ["@clydebarrow"] +DOMAIN = "lsm6ds" CONF_LSM6DS_ID = "lsm6ds_id" # C++ namespace / class diff --git a/esphome/components/lsm6ds/lsm6ds.cpp b/esphome/components/lsm6ds/lsm6ds.cpp index efdd241578..b02a610c0a 100644 --- a/esphome/components/lsm6ds/lsm6ds.cpp +++ b/esphome/components/lsm6ds/lsm6ds.cpp @@ -4,7 +4,7 @@ namespace esphome::lsm6ds { -static const char *const TAG = "lsm6ds"; +ESPHOME_LOG_TAG(TAG, "lsm6ds"); static const struct { uint8_t who_am_i; diff --git a/esphome/components/ltr390/__init__.py b/esphome/components/ltr390/__init__.py index e69de29bb2..3bf5ae2563 100644 --- a/esphome/components/ltr390/__init__.py +++ b/esphome/components/ltr390/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ltr390" diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index dd78b20f2c..d9699afe5a 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -5,7 +5,7 @@ namespace esphome::ltr390 { -static const char *const TAG = "ltr390"; +ESPHOME_LOG_TAG(TAG, "ltr390"); static const uint8_t LTR390_WAKEUP_TIME = 10; static const uint8_t LTR390_SETTLE_TIME = 5; diff --git a/esphome/components/ltr501/__init__.py b/esphome/components/ltr501/__init__.py index dd06cfffea..450e38be0f 100644 --- a/esphome/components/ltr501/__init__.py +++ b/esphome/components/ltr501/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "ltr501" diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index afdc271167..91fca7fa9b 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -8,7 +8,7 @@ using esphome::i2c::ErrorCode; namespace esphome::ltr501 { -static const char *const TAG = "ltr501"; +ESPHOME_LOG_TAG(TAG, "ltr501"); static const uint8_t MAX_TRIES = 5; static const uint8_t MAX_SENSITIVITY_ADJUSTMENTS = 10; diff --git a/esphome/components/ltr501/sensor.py b/esphome/components/ltr501/sensor.py index e27a7c323e..51378e4cdd 100644 --- a/esphome/components/ltr501/sensor.py +++ b/esphome/components/ltr501/sensor.py @@ -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/__init__.py b/esphome/components/ltr_als_ps/__init__.py index dd06cfffea..3b6fa0d268 100644 --- a/esphome/components/ltr_als_ps/__init__.py +++ b/esphome/components/ltr_als_ps/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "ltr_als_ps" diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index 0d43aac20e..2d3b3755ad 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -8,7 +8,7 @@ using esphome::i2c::ErrorCode; namespace esphome::ltr_als_ps { -static const char *const TAG = "ltr_als_ps"; +ESPHOME_LOG_TAG(TAG, "ltr_als_ps"); static const uint8_t MAX_TRIES = 5; diff --git a/esphome/components/ltr_als_ps/sensor.py b/esphome/components/ltr_als_ps/sensor.py index 7bd476156b..223528581e 100644 --- a/esphome/components/ltr_als_ps/sensor.py +++ b/esphome/components/ltr_als_ps/sensor.py @@ -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 2d2f1d6288..9b2f41e063 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -17,6 +17,7 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, get_esp32_variant, + idf_version, ) from esphome.components.image import ( CONF_OPAQUE, @@ -31,6 +32,7 @@ from esphome.components.psram import DOMAIN as PSRAM_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_BUILD_FLAGS, CONF_ESPHOME, CONF_GROUP, CONF_ID, @@ -47,6 +49,7 @@ from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +from esphome.types import ConfigType from esphome.writer import clean_build from esphome.yaml_util import load_yaml @@ -60,7 +63,6 @@ from .defines import ( get_focused_widgets, get_lv_images_used, get_refreshed_widgets, - set_widgets_completed, ) from .encoders import ( ENCODERS_CONFIG, @@ -107,6 +109,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 @@ -156,7 +159,7 @@ def as_macro(macro, value): return f"#define {macro} {value}" -LVGL_VERSION = "9.5.0" +LVGL_VERSION = "9.6.0" LV_CONF_FILENAME = "lv_conf.h" LV_CONF_H_FORMAT = """\ #pragma once @@ -169,11 +172,17 @@ def generate_lv_conf_h(): all_defines = set( df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES) ) - build_flags = ( - CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", []) + esphome_config = CORE.config[CONF_ESPHOME] + # User build flags come from esphome->build_flags and from the deprecated + # esphome->platformio_options->build_flags (a string or a list). + # Remove before 2026.12.0 + + pio_build_flags = esphome_config.get(CONF_PLATFORMIO_OPTIONS, {}).get( + CONF_BUILD_FLAGS, [] ) - if not isinstance(build_flags, list): - build_flags = [build_flags] + if not isinstance(pio_build_flags, list): + pio_build_flags = [pio_build_flags] + build_flags = [*esphome_config.get(CONF_BUILD_FLAGS, []), *pio_build_flags] # Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART', # or multiple defines in one string. define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?' @@ -231,6 +240,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( @@ -315,10 +325,16 @@ async def to_code(configs): # Skip compiling lvgl examples add_idf_sdkconfig_option("CONFIG_LV_BUILD_EXAMPLES", False) add_idf_sdkconfig_option("CONFIG_LV_BUILD_DEMOS", False) + # Match LV_USE_STDLIB_MALLOC in lv_conf.h. This also drops the builtin malloc + # options, which LVGL 9.5 builds leave set to deprecated values. + add_idf_sdkconfig_option("CONFIG_LV_USE_CUSTOM_MALLOC", True) if get_esp32_variant() == VARIANT_ESP32P4: add_idf_sdkconfig_option("CONFIG_LV_DRAW_BUF_ALIGN", 64) - # disable use of PPA for fills until upstream bugs fixed - df.add_define("LV_USE_PPA", "0") + # The LVGL PPA driver uses cache functions added in ESP-IDF 6.0 + if idf_version() >= cv.Version(6, 0, 0): + df.add_define("LV_USE_PPA", "1") + else: + df.add_define("LV_USE_PPA", "0") df.add_define("LV_DRAW_BUF_ALIGN", "64") else: df.add_define("LV_DRAW_BUF_ALIGN", "32") @@ -328,6 +344,8 @@ async def to_code(configs): cg.add_library("lvgl/lvgl", LVGL_VERSION) df.add_define("LV_DRAW_BUF_STRIDE_ALIGN", "1") df.add_define("LV_USE_DRAW_SW", "1") + # LVGL 9.6 rejects 0 here unless tiled rendering is disabled + df.add_define("LV_DRAW_SW_DRAW_UNIT_CNT", "1") df.add_define("LV_USE_STDLIB_SPRINTF", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_STRING", "LV_STDLIB_CLIB") df.add_define("LV_USE_STDLIB_MALLOC", "LV_STDLIB_CUSTOM") @@ -347,15 +365,22 @@ async def to_code(configs): "LVGL_LOG_LEVEL", cg.RawExpression(f"ESPHOME_LOG_LEVEL_{config_0[CONF_LOG_LEVEL]}"), ) - df.add_define("LV_COLOR_DEPTH", config_0[CONF_COLOR_DEPTH]) + # Big-endian displays get LVGL to render directly in swapped byte order + display_format = ( + "RGB565_SWAPPED" if config_0[CONF_BYTE_ORDER] == BYTE_ORDER_BIG else "RGB565" + ) + df.add_define("LV_COLOR_FORMAT_DEFAULT", f"LV_COLOR_FORMAT_{display_format}") for font in df.get_lv_fonts_used(): df.add_define(f"LV_FONT_{font.upper()}") - - if config_0[CONF_COLOR_DEPTH] == 16: + df.add_define("LV_OBJ_STYLE_CACHE", "1") + if any(config[df.CONF_CHECK_ARGS] for config in configs): + df.add_define("LV_USE_CHECK_ARG", "1") df.add_define( - "LV_COLOR_16_SWAP", - "1" if config_0[CONF_BYTE_ORDER] == "big_endian" else "0", + "LV_CHECK_ARG_LOG_MODE", + f"LV_CHECK_ARG_LOG_MODE_{df.LV_CHECK_ARG_LOG_MODES[config_0[CONF_LOG_LEVEL]]}", ) + else: + df.add_define("LV_USE_CHECK_ARG", "0") df.add_define( "LV_COLOR_CHROMA_KEY", await lvalid.lv_color.process(config_0[df.CONF_TRANSPARENCY_KEY]), @@ -386,6 +411,12 @@ async def to_code(configs): df.add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font)) cg.add(lvgl_static.esphome_lvgl_init()) default_group = get_default_group(config_0) + df.get_options()[df.CONF_DEBUG_OUTLINE] = config_0[df.CONF_DEBUG_OUTLINE] + + # Create theme lambdas before any widgets. + async with LvContext(): + for config in configs: + await theme_to_code(config) for config in configs: frac = config[CONF_BUFFER_SIZE] @@ -439,7 +470,6 @@ async def to_code(configs): await touchscreens_to_code(lv_component, config) await encoders_to_code(lv_component, config, default_group) await keypads_to_code(lv_component, config, default_group) - await theme_to_code(config) await gradients_to_code(config) await styles_to_code(config) await set_obj_properties(lv_scr_act, config) @@ -450,20 +480,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, [])) @@ -496,8 +520,9 @@ async def to_code(configs): if configs[0].get(df.CONF_THEME, {}).get(df.CONF_DARK_MODE): df.add_define("LV_THEME_DEFAULT_DARK", "1") - # Currently always need RGB565 for the display buffer, and ARGB8888 is used for layer blending - lv_image_formats = {"RGB565", "ARGB8888"} + # The display buffer format is always needed, RGB565 is used by canvas buffers and + # ARGB8888 for layer blending + lv_image_formats = {"RGB565", display_format, "ARGB8888"} for image_id in get_lv_images_used(): await cg.get_variable(image_id) @@ -511,7 +536,7 @@ async def to_code(configs): if image_type == ImageRGB565: lv_image_formats.add("RGB565A8" if transparent else "RGB565") if image_type == ImageRGB: - lv_image_formats.add("ARGB8888" if transparent else "RGB8888") + lv_image_formats.add("ARGB8888" if transparent else "RGB888") if df.is_defined("LV_GRADIENT_MAX_STOPS"): lv_image_formats.add("RGB888") for fmt in lv_image_formats: @@ -587,6 +612,7 @@ LVGL_TOP_LEVEL_SCHEMA = ( cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), + cv.Optional(df.CONF_CHECK_ARGS, default=False): cv.boolean, cv.Optional(CONF_BYTE_ORDER): cv.one_of( "big_endian", "little_endian", lower=True ), @@ -627,13 +653,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..f1c3887d5c 100644 --- a/esphome/components/lvgl/animation.py +++ b/esphome/components/lvgl/animation.py @@ -15,11 +15,10 @@ from esphome.const import ( ) from esphome.cpp_generator import MockObj, TemplateArguments -from ..const import CONF_LOOP +from ..const import CONF_LOOP, CONF_ON_STOP from .defines import ( CONF_AUTO_START, CONF_LVGL_ID, - CONF_ON_STOP, CONF_WIDGETS, LValidator, add_define, @@ -76,6 +75,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 c23a36c389..4e8698f33c 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -8,6 +8,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ACTION, CONF_GROUP, + CONF_HIDDEN, CONF_ID, CONF_POSITION, CONF_ROTATION, @@ -78,7 +79,6 @@ from .widgets import ( get_screen_active, get_widgets, set_obj_properties, - wait_for_widgets, ) # Widgets that are used in a focused/refreshed action are tracked in @@ -127,15 +127,7 @@ async def action_to_code( action_id, template_arg, args, - config=None, ): - # Ensure all required ids have been processed, so our LambdaContext doesn't get context-switched. - if config: - for lamb in config.values(): - if isinstance(lamb, Lambda): - for id_ in lamb.requires_ids: - await get_variable(id_) - await wait_for_widgets() async with LambdaContext(parameters=args, where=action_id) as context: for widget in widgets: await action(widget) @@ -145,7 +137,7 @@ async def action_to_code( async def update_to_code(config, action_id, template_arg, args): async def do_update(widget: Widget): await set_obj_properties(widget, config) - await widget.type.to_code(widget, config) + await widget.type.update_to_code(widget, config) if ( widget.type.w_type.value_property is not None and widget.type.w_type.value_property in config @@ -153,9 +145,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( @@ -365,7 +355,7 @@ async def obj_enable_to_code(config, action_id, template_arg, args): ) async def obj_hide_to_code(config, action_id, template_arg, args): async def do_hide(widget: Widget): - widget.add_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, True) widgets = [widget.outer or widget for widget in await get_widgets(config)] return await action_to_code(widgets, do_hide, action_id, template_arg, args) @@ -376,7 +366,7 @@ async def obj_hide_to_code(config, action_id, template_arg, args): ) async def obj_show_to_code(config, action_id, template_arg, args): async def do_show(widget: Widget): - widget.clear_flag("LV_OBJ_FLAG_HIDDEN") + widget.set_flag(CONF_HIDDEN, False) if widget.move_to_foreground: lv_obj.move_foreground(widget.obj) @@ -507,9 +497,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..e658735c75 100644 --- a/esphome/components/lvgl/binary_sensor/__init__.py +++ b/esphome/components/lvgl/binary_sensor/__init__.py @@ -6,10 +6,10 @@ from esphome.components.binary_sensor import ( import esphome.config_validation as cv from esphome.const import CONF_STATE -from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant +from ..defines import CONF_CLICKABLE, CONF_WIDGET, 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: @@ -42,7 +41,7 @@ async def to_code(config): ctx.add(sensor.publish_initial_state(test_expr)) if is_pressed: events = [LV_EVENT.PRESSED, LV_EVENT.RELEASED] - widget.add_flag(LV_OBJ_FLAG.CLICKABLE) + widget.set_flag(CONF_CLICKABLE, True) else: events = [LV_EVENT.VALUE_CHANGED, UPDATE_EVENT] ctx.add( diff --git a/esphome/components/lvgl/color_picker.h b/esphome/components/lvgl/color_picker.h new file mode 100644 index 0000000000..e2392694f0 --- /dev/null +++ b/esphome/components/lvgl/color_picker.h @@ -0,0 +1,625 @@ +#pragma once + +#include "lvgl_esphome.h" +#include "esphome/core/color.h" + +#include +#include +#include +#include +#include + +#ifdef USE_LVGL_COLOR_PICKER + +namespace esphome::lvgl { + +// The hue gradients need seven stops; gradient.py raises the limit when a colour picker is in use. +static_assert(LV_GRADIENT_MAX_STOPS >= 7, "color_picker needs LV_GRADIENT_MAX_STOPS >= 7"); + +class LvColorPickerType : public LvCompound { + public: + enum SliderIndex : size_t { + SLIDER_HUE, + SLIDER_SATURATION, + SLIDER_BRIGHTNESS, + SLIDER_RED, + SLIDER_GREEN, + SLIDER_BLUE, + SLIDER_COUNT, + }; + + enum SliderFlag : uint8_t { + SLIDER_FLAG_HUE = 1 << SLIDER_HUE, + SLIDER_FLAG_SATURATION = 1 << SLIDER_SATURATION, + SLIDER_FLAG_BRIGHTNESS = 1 << SLIDER_BRIGHTNESS, + SLIDER_FLAG_RED = 1 << SLIDER_RED, + SLIDER_FLAG_GREEN = 1 << SLIDER_GREEN, + SLIDER_FLAG_BLUE = 1 << SLIDER_BLUE, + }; + + explicit LvColorPickerType(uint8_t sliders, bool use_wheel) : sliders_mask_(sliders), use_wheel_(use_wheel) {} + + // Returns null for a slider the widget was not built with. + lv_obj_t *get_slider(size_t index) const { return this->sliders_[index]; } + + void set_tint_knobs(bool tint_knobs) { this->tint_knobs_ = tint_knobs; } + + void set_color(lv_color_t color) { + lv_color32_t c32 = lv_color_to_32(color, LV_OPA_COVER); + this->state_ = Color(c32.red, c32.green, c32.blue); + if (this->obj != nullptr) + this->update_color_(); + } + Color get_color() const { return this->state_; } + + void set_obj(lv_obj_t *outer) override { + LvCompound::set_obj(outer); + init_shared(); + // The far end of these two follows the current hue. + init_bar(&this->saturation_bar_, LV_GRAD_DIR_HOR, lv_color_white(), lv_color_hex(0xFF0000)); + init_bar(&this->brightness_bar_, LV_GRAD_DIR_HOR, lv_color_black(), lv_color_white()); + + auto channels = static_cast(this->enabled_(SLIDER_RED)) + + static_cast(this->enabled_(SLIDER_GREEN)) + + static_cast(this->enabled_(SLIDER_BLUE)); + + // No layout on the outer object, so LV_SIZE_CONTENT uses the size from self_size_cb. + lv_obj_add_event_cb(outer, self_size_cb, LV_EVENT_GET_SELF_SIZE, nullptr); + lv_obj_add_event_cb(outer, size_changed_cb, LV_EVENT_SIZE_CHANGED, this); + lv_obj_set_style_pad_all(outer, KNOB_OVERHANG, LV_PART_MAIN); + + int32_t left_width = 0; + lv_obj_t *left = nullptr; + if (this->enabled_(SLIDER_HUE)) { + left_width = HUE_WIDTH_PCT; + left = this->create_slider_(outer, SLIDER_HUE, "Hue", true, &hue_bar, 360); + lv_obj_set_size(left, lv_pct(HUE_WIDTH_PCT), lv_pct(100)); + } +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + else if (this->use_wheel_) { + left_width = WHEEL_WIDTH_PCT; + left = this->create_wheel_(outer); + lv_obj_set_size(left, lv_pct(WHEEL_WIDTH_PCT), lv_pct(WHEEL_WIDTH_PCT)); + } +#endif + if (left != nullptr) { + lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN); + // A narrow hue column may be slimmer than its label. + lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + } + + // The text rows keep their natural height and the bars between them take what is left, + // so a small widget gets thinner bars rather than clipped text. + auto *middle = create_flex(outer, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START); + lv_obj_set_style_align(middle, LV_ALIGN_RIGHT_MID, LV_PART_MAIN); + lv_obj_set_style_pad_row(middle, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_size(middle, lv_pct(100 - left_width), lv_pct(100)); + + // A dark bar needs light text, unlike every other value label. + if (this->enabled_(SLIDER_BRIGHTNESS)) + this->create_row_slider_(middle, SLIDER_BRIGHTNESS, "Brightness", &this->brightness_bar_, lv_color_white()); + + auto *inner = create_flex(middle, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_START); + lv_obj_set_width(inner, lv_pct(100)); + lv_obj_set_flex_grow(inner, 1); + + // The channel bars keep a fixed width each, so dropping one widens the swatch. + auto channels_width = CHANNEL_WIDTH_PCT * channels; + auto *indicator_container = create_flex(inner, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); + lv_obj_set_size(indicator_container, lv_pct(CONTENT_WIDTH_PCT - channels_width), lv_pct(100)); + lv_obj_set_style_pad_ver(indicator_container, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_style_pad_row(indicator_container, KNOB_OVERHANG, LV_PART_MAIN); + + this->color_text_ = lv_label_create(indicator_container); + this->color_indicator_ = lv_obj_create(indicator_container); + lv_obj_set_style_border_color(this->color_indicator_, lv_color_hex(0x808080), LV_PART_MAIN); + lv_obj_set_style_border_width(this->color_indicator_, 1, LV_PART_MAIN); + lv_obj_set_style_radius(this->color_indicator_, 0, LV_PART_MAIN); + lv_obj_set_width(this->color_indicator_, lv_pct(90)); + lv_obj_set_flex_grow(this->color_indicator_, 1); + + if (channels != 0) { + auto *rgb_container = create_flex(inner, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_SPACE_BETWEEN); + lv_obj_set_size(rgb_container, lv_pct(channels_width), lv_pct(100)); + struct ChannelInfo { + SliderIndex index; + const char *name; + const lv_grad_dsc_t *grad; + }; + const std::array channel_info{{ + {SLIDER_RED, "R", &red_bar}, + {SLIDER_GREEN, "G", &green_bar}, + {SLIDER_BLUE, "B", &blue_bar}, + }}; + for (const auto &channel : channel_info) { + if (!this->enabled_(channel.index)) + continue; + auto *container = this->create_slider_(rgb_container, channel.index, channel.name, true, channel.grad, 255); + lv_obj_set_size(container, lv_pct(100 / channels), lv_pct(100)); + this->add_value_label_(channel.index, lv_color_black()); + } + } + + if (this->enabled_(SLIDER_SATURATION)) + this->create_row_slider_(middle, SLIDER_SATURATION, "Saturation", &this->saturation_bar_, lv_color_black()); + + this->update_color_(); + + for (size_t index = 0; index < SLIDER_COUNT; index++) { + if (this->sliders_[index] == nullptr) + continue; + lv_obj_add_event_cb(this->sliders_[index], index <= SLIDER_BRIGHTNESS ? hsv_changed_cb : rgb_changed_cb, + LV_EVENT_VALUE_CHANGED, this); + } + + bubble_events(outer); + } + + protected: + // LVGL keeps only pointers to gradients and styles, so one copy serves every colour picker. + static void init_shared() { + if (shared_ready) + return; + shared_ready = true; + + // Clockwise from red at 3 o'clock, matching how lv_color_hsv_to_rgb() reads the angle. + std::array hues{lv_color_hex(0xFF0000), lv_color_hex(0xFFFF00), lv_color_hex(0x00FF00), + lv_color_hex(0x00FFFF), lv_color_hex(0x0000FF), lv_color_hex(0xFF00FF), + lv_color_hex(0xFF0000)}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + lv_grad_conical_init(&wheel_hue_grad, lv_pct(50), lv_pct(50), 0, 360, LV_GRAD_EXTEND_PAD); + lv_grad_init_stops(&wheel_hue_grad, hues.data(), nullptr, nullptr, static_cast(hues.size())); + // White at the centre fading out at the rim, giving saturation over the hue wheel. + const lv_color_t whites[] = {lv_color_white(), lv_color_white()}; + const lv_opa_t white_opas[] = {LV_OPA_COVER, LV_OPA_TRANSP}; + lv_grad_radial_init(&wheel_white_grad, lv_pct(50), lv_pct(50), lv_pct(100), lv_pct(50), LV_GRAD_EXTEND_PAD); + lv_grad_init_stops(&wheel_white_grad, whites, white_opas, nullptr, 2); +#endif + // A vertical slider has its minimum at the bottom, so the hue bar runs the other way. + std::reverse(hues.begin(), hues.end()); + lv_grad_vertical_init(&hue_bar); + lv_grad_init_stops(&hue_bar, hues.data(), nullptr, nullptr, static_cast(hues.size())); + + init_bar(&red_bar, LV_GRAD_DIR_VER, lv_color_hex(0xFF0000), lv_color_white()); + init_bar(&green_bar, LV_GRAD_DIR_VER, lv_color_hex(0x00FF00), lv_color_white()); + init_bar(&blue_bar, LV_GRAD_DIR_VER, lv_color_hex(0x0000FF), lv_color_white()); + + lv_style_init(&plain_box); + lv_style_set_bg_opa(&plain_box, LV_OPA_TRANSP); + lv_style_set_border_width(&plain_box, 0); + lv_style_set_pad_all(&plain_box, 0); + lv_style_set_pad_row(&plain_box, 0); + lv_style_set_pad_column(&plain_box, 0); + + init_slider_styles(&slider_vert, &slider_vert_knob, true); + init_slider_styles(&slider_horz, &slider_horz_knob, false); + } + + static void init_bar(lv_grad_dsc_t *grad, lv_grad_dir_t dir, lv_color_t from, lv_color_t to) { + const lv_color_t colors[] = {from, to}; + grad->dir = dir; + lv_grad_init_stops(grad, colors, nullptr, nullptr, 2); + } + + static void init_slider_styles(lv_style_t *bar, lv_style_t *knob, bool vertical) { + lv_style_init(bar); + lv_style_set_align(bar, LV_ALIGN_CENTER); + lv_style_set_bg_opa(bar, LV_OPA_COVER); + lv_style_set_pad_all(bar, 0); + lv_style_set_radius(bar, 0); + // A horizontal bar's thickness is set by update_bar_thickness_(). + if (vertical) { + lv_style_set_width(bar, lv_pct(50)); + lv_style_set_height(bar, lv_pct(90)); + } else { + lv_style_set_width(bar, lv_pct(90)); + } + + lv_style_init(knob); + lv_style_set_bg_color(knob, lv_color_white()); + lv_style_set_border_width(knob, 1); + lv_style_set_outline_color(knob, lv_color_black()); + lv_style_set_radius(knob, 0); + // Both axes are set, or the theme picks a knob size the layout has not left room for. + int32_t along = -KNOB_TRIM; + int32_t across = KNOB_OVERHANG; + lv_style_set_pad_top(knob, vertical ? along : across); + lv_style_set_pad_bottom(knob, vertical ? along : across); + lv_style_set_pad_left(knob, vertical ? across : along); + lv_style_set_pad_right(knob, vertical ? across : along); + } + + // A borderless, unpadded container. + static lv_obj_t *create_box(lv_obj_t *parent) { + auto *obj = lv_obj_create(parent); + lv_obj_add_style(obj, &plain_box, LV_PART_MAIN); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + return obj; + } + + static lv_obj_t *create_flex(lv_obj_t *parent, lv_flex_flow_t flow, lv_flex_align_t main_place, + lv_flex_align_t track_place = LV_FLEX_ALIGN_START) { + auto *obj = create_box(parent); + lv_obj_set_layout(obj, LV_LAYOUT_FLEX); + lv_obj_set_flex_flow(obj, flow); + lv_obj_set_flex_align(obj, main_place, LV_FLEX_ALIGN_CENTER, track_place); + return obj; + } + + // Preferred size, in lines of the text font. The widget is square. + static constexpr int SELF_SIZE_FONT_MULTIPLE = 16; + + // At either end of its travel half the knob lies outside the bar. KNOB_TRIM pulls it back + // along the bar, and KNOB_OVERHANG makes it stand out across the bar. + static constexpr int KNOB_TRIM = 4; + static constexpr int KNOB_OVERHANG = 4; + + // Layout proportions, as percentages. + static constexpr int32_t HUE_WIDTH_PCT = 15; + static constexpr int32_t CHANNEL_WIDTH_PCT = 19; + static constexpr int32_t CONTENT_WIDTH_PCT = 99; + static constexpr int32_t WHEEL_WIDTH_PCT = 45; + static constexpr int32_t WHEEL_HANDLE_SIZE = 14; + + bool enabled_(SliderIndex index) const { return (this->sliders_mask_ & (1 << index)) != 0; } + bool has_(SliderIndex index) const { return this->sliders_[index] != nullptr; } + bool can_change_saturation_() const { + return this->use_wheel_ || (this->sliders_mask_ & + (SLIDER_FLAG_SATURATION | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != 0; + } + bool can_change_brightness_() const { + return (this->sliders_mask_ & (SLIDER_FLAG_BRIGHTNESS | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != + 0; + } + + // A slider the widget was built without keeps the value held internally. + int32_t slider_value_(SliderIndex index, int32_t fallback) const { + return this->has_(index) ? lv_slider_get_value(this->sliders_[index]) : fallback; + } + + void set_slider_value_(SliderIndex index, int32_t value) { + if (this->has_(index)) + lv_slider_set_value(this->sliders_[index], value, LV_ANIM_OFF); + } + + // Builds a slider with a name label above it, and returns its container. + lv_obj_t *create_slider_(lv_obj_t *parent, SliderIndex index, const char *name, bool vertical, + const lv_grad_dsc_t *grad, int32_t max) { + // A column keeps the label on its own row however large the font is. + auto *container = create_flex(parent, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER); + if (vertical) { + // How far the knob reaches past the ends depends on the font, so follow style changes. + update_knob_clearance(container); + lv_obj_add_event_cb(container, knob_clearance_cb, LV_EVENT_STYLE_CHANGED, nullptr); + } else { + lv_obj_set_style_pad_bottom(container, KNOB_OVERHANG, LV_PART_MAIN); + lv_obj_set_style_pad_row(container, KNOB_OVERHANG, LV_PART_MAIN); + } + lv_label_set_text(lv_label_create(container), name); + + auto *slider = lv_slider_create(container); + this->sliders_[index] = slider; + lv_obj_add_style(slider, vertical ? &slider_vert : &slider_horz, LV_PART_MAIN); + lv_obj_set_style_bg_grad(slider, grad, LV_PART_MAIN); + lv_obj_set_style_bg_opa(slider, LV_OPA_TRANSP, LV_PART_INDICATOR); + lv_obj_add_style(slider, vertical ? &slider_vert_knob : &slider_horz_knob, LV_PART_KNOB); + if (vertical) + lv_obj_set_flex_grow(slider, 1); + lv_slider_set_range(slider, 0, max); + lv_slider_set_mode(slider, LV_SLIDER_MODE_NORMAL); + return container; + } + + void create_row_slider_(lv_obj_t *parent, SliderIndex index, const char *name, const lv_grad_dsc_t *grad, + lv_color_t text_color) { + auto *container = this->create_slider_(parent, index, name, false, grad, 100); + lv_obj_set_size(container, lv_pct(100), LV_SIZE_CONTENT); + this->add_value_label_(index, text_color); + } + +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // A full-size circle filled with a gradient. + static lv_obj_t *create_disc(lv_obj_t *parent, const lv_grad_dsc_t *grad) { + auto *disc = create_box(parent); + lv_obj_set_size(disc, lv_pct(100), lv_pct(100)); + lv_obj_set_style_align(disc, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN); + lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE); + return disc; + } + + // Builds the colour wheel that replaces the hue and saturation sliders, and returns its container. + lv_obj_t *create_wheel_(lv_obj_t *parent) { + auto *container = create_box(parent); + this->wheel_ = create_disc(container, &wheel_hue_grad); + // Not clickable, so touches fall through to the wheel underneath. + auto *overlay = create_disc(this->wheel_, &wheel_white_grad); + lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE); + + this->wheel_handle_ = lv_obj_create(this->wheel_); + lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE); + lv_obj_set_style_align(this->wheel_handle_, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_radius(this->wheel_handle_, LV_RADIUS_CIRCLE, LV_PART_MAIN); + lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN); + lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN); + lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE); + + // PRESSED as well, so a tap takes effect without the finger moving. + lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this); + lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSING, this); + // The handle can only be placed once the wheel has been laid out. + lv_obj_add_event_cb(this->wheel_, wheel_size_cb, LV_EVENT_SIZE_CHANGED, this); + return container; + } +#endif + + void add_value_label_(SliderIndex index, lv_color_t color) { + auto *slider = this->sliders_[index]; + lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + auto *label = lv_label_create(slider); + this->values_[index] = label; + lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_text_color(label, color, LV_PART_MAIN); + } + + // Leaves room at the ends of a vertical slider for the overhanging knob: half a line of text. + static void update_knob_clearance(lv_obj_t *container) { + const auto *font = lv_obj_get_style_text_font(container, LV_PART_MAIN); + auto clearance = static_cast(lv_font_get_line_height(font)) / 2; + if (clearance == lv_obj_get_style_pad_bottom(container, LV_PART_MAIN)) + return; + lv_obj_set_style_pad_bottom(container, clearance, LV_PART_MAIN); + lv_obj_set_style_pad_row(container, clearance, LV_PART_MAIN); + } + + static void knob_clearance_cb(lv_event_t *event) { update_knob_clearance(lv_event_get_current_target_obj(event)); } + + static LvColorPickerType *from_event(lv_event_t *event) { + return static_cast(lv_event_get_user_data(event)); + } + static void hsv_changed_cb(lv_event_t *event) { from_event(event)->update_hsv_(); } + static void rgb_changed_cb(lv_event_t *event) { from_event(event)->update_rgb_(); } +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + static void wheel_touch_cb(lv_event_t *event) { from_event(event)->update_from_wheel_touch_(); } + static void wheel_size_cb(lv_event_t *event) { from_event(event)->update_wheel_handle_(); } +#endif + static void size_changed_cb(lv_event_t *event) { from_event(event)->update_bar_thickness_(); } + + // Gives the horizontal bars the thickness of the vertical ones, half a hue column, so every + // knob is the same size. A knob is sized from the thickness of its bar. + void update_bar_thickness_() { + auto thickness = lv_obj_get_content_width(this->obj) * HUE_WIDTH_PCT / 200; + for (auto index : {SLIDER_SATURATION, SLIDER_BRIGHTNESS}) { + if (this->has_(index) && lv_obj_get_style_height(this->sliders_[index], LV_PART_MAIN) != thickness) + lv_obj_set_style_height(this->sliders_[index], thickness, LV_PART_MAIN); + } + } + + // LVGL also counts this size when working out the scroll area, so a widget given a fixed + // width must not claim it, or it would scroll whenever it is smaller. + static void self_size_cb(lv_event_t *event) { + auto *obj = lv_event_get_current_target_obj(event); + if (lv_obj_get_style_width(obj, LV_PART_MAIN) != LV_SIZE_CONTENT) + return; + const auto *font = lv_obj_get_style_text_font(obj, LV_PART_MAIN); + auto self_size = static_cast(lv_font_get_line_height(font)) * SELF_SIZE_FONT_MULTIPLE; + auto *point = static_cast(lv_event_get_param(event)); + point->x = self_size; + point->y = self_size; + } + + void update_text_() const { + // "#" + 3x "%02X" + null + char buf[8]; + snprintf(buf, sizeof(buf), "#%02X%02X%02X", static_cast(this->state_.r), + static_cast(this->state_.g), static_cast(this->state_.b)); + lv_label_set_text(this->color_text_, buf); + } + + void update_hue_bar_(SliderIndex index, lv_grad_dsc_t *grad, lv_color_t color) { + if (!this->has_(index)) + return; + grad->stops[1].color = color; + lv_obj_invalidate(this->sliders_[index]); + } + + void update_values_() const { + for (size_t index = 0; index < SLIDER_COUNT; index++) { + if (this->values_[index] == nullptr) + continue; + // up to 3 digits + null + char buf[4]; + snprintf(buf, sizeof(buf), "%d", static_cast(lv_slider_get_value(this->sliders_[index]))); + lv_label_set_text(this->values_[index], buf); + } + } + + void tint_knob_(SliderIndex index, lv_color_t color) const { + if (this->has_(index)) + lv_obj_set_style_bg_color(this->sliders_[index], color, LV_PART_KNOB); + } + + // Tints each knob with the colour of its bar at the current value. The brightness knob + // shows the selected colour instead, since its bar leaves out saturation. + void update_knobs_(lv_color_t full_hue) const { + if (!this->tint_knobs_) + return; + this->tint_knob_(SLIDER_HUE, full_hue); + this->tint_knob_(SLIDER_SATURATION, lv_color_hsv_to_rgb(this->hue_, this->saturation_, 100)); + this->tint_knob_(SLIDER_BRIGHTNESS, this->state_); + this->tint_knob_(SLIDER_RED, lv_color_make(255, 255 - this->state_.r, 255 - this->state_.r)); + this->tint_knob_(SLIDER_GREEN, lv_color_make(255 - this->state_.g, 255, 255 - this->state_.g)); + this->tint_knob_(SLIDER_BLUE, lv_color_make(255 - this->state_.b, 255 - this->state_.b, 255)); + } + +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // Returns the wheel's radius, which is zero until it has been laid out. + double wheel_radius_(lv_area_t *area) const { + lv_obj_get_coords(this->wheel_, area); + return static_cast(area->x2 - area->x1) / 2.0; + } + + void update_wheel_handle_() { + if (this->wheel_handle_ == nullptr) + return; + lv_area_t area; + auto radius = this->wheel_radius_(&area); + if (radius <= 0) + return; + auto angle_rad = static_cast(this->hue_) * (std::numbers::pi / 180.0); + auto distance = (static_cast(this->saturation_) / 100.0) * radius; + lv_obj_set_style_translate_x(this->wheel_handle_, static_cast(std::lround(distance * std::cos(angle_rad))), + LV_PART_MAIN); + lv_obj_set_style_translate_y(this->wheel_handle_, static_cast(std::lround(distance * std::sin(angle_rad))), + LV_PART_MAIN); + } + + // The angle from the centre gives the hue, the distance the saturation. Brightness is unchanged. + void update_from_wheel_touch_() { + lv_indev_t *indev = lv_indev_active(); + if (indev == nullptr) + return; + lv_point_t point; + lv_indev_get_point(indev, &point); + lv_area_t area; + auto radius = this->wheel_radius_(&area); + if (radius <= 0) + return; + auto dx = point.x - (area.x1 + area.x2) / 2.0; + auto dy = point.y - (area.y1 + area.y2) / 2.0; + auto angle = std::atan2(dy, dx) * (180.0 / std::numbers::pi); + if (angle < 0) + angle += 360.0; + this->hue_ = static_cast(std::lround(angle)) % 360; + auto distance = std::sqrt(dx * dx + dy * dy); + this->saturation_ = static_cast(std::lround(std::min(100.0, distance * 100.0 / radius))); + this->brightness_ = static_cast(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_)); + this->apply_hsv_(); + // Only sliders send this themselves; without it on_value would not fire for the wheel. + lv_obj_send_event(this->wheel_, LV_EVENT_VALUE_CHANGED, nullptr); + } +#endif + + // Lets events from the internal sliders reach the widget, where the triggers listen. Every + // object in between needs the flag; the widget itself does not, so events stop there. + static void bubble_events(lv_obj_t *obj) { + for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) { + auto *child = lv_obj_get_child(obj, i); + lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE); + bubble_events(child); + } + } + + void refresh_() { + lv_obj_set_style_bg_color(this->color_indicator_, this->state_, LV_PART_MAIN); + auto full_hue = lv_color_hsv_to_rgb(this->hue_, 100, 100); + this->update_hue_bar_(SLIDER_SATURATION, &this->saturation_bar_, full_hue); + this->update_hue_bar_(SLIDER_BRIGHTNESS, &this->brightness_bar_, full_hue); + this->update_text_(); + this->update_values_(); + this->update_knobs_(full_hue); +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + this->update_wheel_handle_(); +#endif + } + + // Sets the colour from the hue, saturation and brightness, and moves the RGB sliders to match. + void apply_hsv_() { + lv_color32_t c32 = + lv_color_to_32(lv_color_hsv_to_rgb(this->hue_, this->saturation_, this->brightness_), LV_OPA_COVER); + this->state_ = Color(c32.red, c32.green, c32.blue); + this->set_slider_value_(SLIDER_RED, c32.red); + this->set_slider_value_(SLIDER_GREEN, c32.green); + this->set_slider_value_(SLIDER_BLUE, c32.blue); + this->refresh_(); + } + + void update_hsv_() { + this->hue_ = static_cast(this->slider_value_(SLIDER_HUE, this->hue_)); + this->saturation_ = static_cast(this->slider_value_(SLIDER_SATURATION, this->saturation_)); + this->brightness_ = static_cast(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_)); + this->apply_hsv_(); + } + + void update_rgb_() { + this->state_ = Color(static_cast(this->slider_value_(SLIDER_RED, this->state_.r)), + static_cast(this->slider_value_(SLIDER_GREEN, this->state_.g)), + static_cast(this->slider_value_(SLIDER_BLUE, this->state_.b))); + auto hsv = lv_color_rgb_to_hsv(this->state_.r, this->state_.g, this->state_.b); + this->hue_ = hsv.h; + this->saturation_ = hsv.s; + this->brightness_ = hsv.v; + // A grey, white or black colour leaves a picker stuck there if nothing it has can move the + // saturation, or the brightness, away from zero. + bool stuck = false; + if (this->saturation_ == 0 && !this->can_change_saturation_()) { + this->saturation_ = 100; + stuck = true; + } + if (this->brightness_ == 0 && !this->can_change_brightness_()) { + this->brightness_ = 100; + stuck = true; + } + this->set_slider_value_(SLIDER_HUE, this->hue_); + this->set_slider_value_(SLIDER_SATURATION, this->saturation_); + this->set_slider_value_(SLIDER_BRIGHTNESS, this->brightness_); + if (stuck) { + this->apply_hsv_(); + } else { + this->refresh_(); + } + } + + void update_color_() { + this->set_slider_value_(SLIDER_RED, this->state_.r); + this->set_slider_value_(SLIDER_GREEN, this->state_.g); + this->set_slider_value_(SLIDER_BLUE, this->state_.b); + this->update_rgb_(); + lv_obj_invalidate(this->obj); + } + + // Shared by every colour picker; set up once by init_shared(). + inline static bool shared_ready{false}; + inline static lv_grad_dsc_t hue_bar{}; + inline static lv_grad_dsc_t red_bar{}; + inline static lv_grad_dsc_t green_bar{}; + inline static lv_grad_dsc_t blue_bar{}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + inline static lv_grad_dsc_t wheel_hue_grad{}; + inline static lv_grad_dsc_t wheel_white_grad{}; +#endif + inline static lv_style_t plain_box{}; + inline static lv_style_t slider_vert{}; + inline static lv_style_t slider_vert_knob{}; + inline static lv_style_t slider_horz{}; + inline static lv_style_t slider_horz_knob{}; + + // Per widget, since their far end follows the chosen hue. + lv_grad_dsc_t saturation_bar_{}; + lv_grad_dsc_t brightness_bar_{}; + Color state_{0x80, 0x80, 0x80}; + std::array sliders_{}; + std::array values_{}; + lv_obj_t *color_indicator_{}; + lv_obj_t *color_text_{}; + // Kept alongside state_ so a slider left out of the widget still has a value. + uint16_t hue_{0}; + uint8_t saturation_{0}; + uint8_t brightness_{50}; + const uint8_t sliders_mask_; + const bool use_wheel_; + bool tint_knobs_{true}; +#ifdef USE_LVGL_COLOR_PICKER_WHEEL + // Null unless use_wheel_ is set. + lv_obj_t *wheel_{}; + lv_obj_t *wheel_handle_{}; +#endif +}; + +} // namespace esphome::lvgl + +#endif // USE_LVGL_COLOR_PICKER diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index f601b4b57a..69e15bdeca 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -20,21 +20,40 @@ DOMAIN = "lvgl" KEY_COLOR_FORMATS = "color_formats" KEY_ESPHOME_FONTS_USED = "esphome_fonts_used" KEY_FOCUSED_WIDGETS = "focused_widgets" +KEY_LIST_TRIGGERS = "list_triggers" KEY_LV_DEFINES = "lv_defines" KEY_LV_FONTS_USED = "lv_fonts_used" KEY_LV_IMAGES_USED = "lv_images_used" KEY_LV_USES = "lv_uses" KEY_NAMED_STYLES = "named_styles" +KEY_OPTIONS = "options" KEY_REFRESHED_WIDGETS = "refreshed_widgets" KEY_REMAPPED_USES = "remapped_uses" KEY_STYLES_USED = "styles_used" KEY_THEME_UPDATE_REQUESTS = "theme_update_requests" -KEY_THEME_WIDGET_MAP = "theme_widget_map" +KEY_THEME_STYLES = "theme_styles" KEY_UPDATED_WIDGETS = "updated_widgets" -KEY_WIDGET_MAP = "widget_map" -KEY_WIDGETS_COMPLETED = "widgets_completed" -KEY_OPTIONS = "options" KEY_WARNINGS = "warnings" +KEY_WIDGET_MAP = "widget_map" +KEY_WIDGET_THEME_STYLES = "widget_theme_styles" +KEY_DEBUG_OUTLINE_COUNT = "debug_outline_count" + +# Colours for the debug outline, in (red, green, blue) order. They are picked to stay +# distinct from each other and to show up on both light and dark backgrounds. +DEBUG_OUTLINE_COLORS = ( + (255, 0, 0), + (0, 160, 0), + (0, 0, 255), + (255, 140, 0), + (200, 0, 200), + (0, 190, 190), + (160, 100, 0), + (255, 0, 120), + (110, 110, 110), + (140, 200, 0), + (0, 110, 255), + (130, 0, 255), +) # Initial set of LVGL features that are always enabled. _INITIAL_LV_USES = frozenset( @@ -102,6 +121,15 @@ def get_options() -> dict[str, Any]: return _get_data(KEY_OPTIONS, {}) +def next_debug_outline_color() -> tuple[int, int, int]: + """Return the next debug outline colour, cycling through the palette.""" + # A one-element list so that the count can be updated in place. + count = _get_data(KEY_DEBUG_OUTLINE_COUNT, [0]) + color = DEBUG_OUTLINE_COLORS[count[0] % len(DEBUG_OUTLINE_COLORS)] + count[0] += 1 + return color + + def get_defines() -> dict[str, str]: return _get_data(KEY_LV_DEFINES, {}) @@ -110,8 +138,14 @@ def get_updated_widgets() -> dict: return _get_data(KEY_UPDATED_WIDGETS, {}) -def get_theme_widget_map() -> dict[str, Any]: - return _get_data(KEY_THEME_WIDGET_MAP, {}) +def get_theme_styles() -> dict[str, MockObj]: + """Get a map of already created theme style names to their corresponding style IDs.""" + return _get_data(KEY_THEME_STYLES, {}) + + +def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, str, str]]]: + """Get the map of widget type names to the list of (style variable, part, state)""" + return _get_data(KEY_WIDGET_THEME_STYLES, {}) def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]: @@ -130,20 +164,6 @@ def get_widget_map() -> dict[str, Any]: return _get_data(KEY_WIDGET_MAP, {}) -def get_widgets_completed() -> bool: - # ``[value]`` rather than the bare value so that we can mutate the - # entry in place; ``CORE.data`` is reset for us between runs. - return _get_data(KEY_WIDGETS_COMPLETED, [False])[0] - - -def set_widgets_completed(value: bool) -> None: - _get_data(KEY_WIDGETS_COMPLETED, [False])[0] = value - - -def is_widget_completed(name: ID) -> bool: - return name in get_widget_map() - - def get_focused_widgets() -> set: return _get_data(KEY_FOCUSED_WIDGETS, set()) @@ -152,6 +172,10 @@ def get_refreshed_widgets() -> set: return _get_data(KEY_REFRESHED_WIDGETS, set()) +def get_list_triggers() -> dict: + return _get_data(KEY_LIST_TRIGGERS, {}) + + def add_define(macro: str, value="1"): lv_defines = get_defines() value = str(value) @@ -453,6 +477,16 @@ LV_LOG_LEVELS = { "NONE": "NONE", } +# Log output for failed LVGL argument checks, chosen from the LVGL log level +LV_CHECK_ARG_LOG_MODES = { + "VERBOSE": "VERBOSE", + "DEBUG": "VERBOSE", + "INFO": "MINIMAL", + "WARN": "MINIMAL", + "ERROR": "NONE", + "NONE": "NONE", +} + LV_LONG_MODES = LvConstant( "LV_LABEL_LONG_", "WRAP", @@ -582,13 +616,20 @@ OBJ_FLAGS = ( "ignore_layout", "floating", "overflow_visible", + "send_draw_task_events", + "event_trickle", + "state_trickle", + "radio_button", + "flex_in_new_track", "layout_1", "layout_2", - "send_draw_task_events", "widget_1", "widget_2", ) -LV_OBJ_FLAG = LvConstant("LV_OBJ_FLAG_", *OBJ_FLAGS) +# Flags whose lv_obj_set_() setter has a different name +FLAG_SETTER_ALIASES = {"layout_1": "flex_in_new_track"} +# Custom flags that have no lv_obj_set_() setter +CUSTOM_FLAGS = ("layout_2", "widget_1", "widget_2") ARC_MODES = LvConstant("LV_ARC_MODE_", "NORMAL", "REVERSE", "SYMMETRICAL") BAR_MODES = LvConstant("LV_BAR_MODE_", "NORMAL", "SYMMETRICAL", "RANGE") @@ -679,11 +720,14 @@ CONF_DISP_BG_OPA = "disp_bg_opa" CONF_BODY = "body" CONF_BUTTONS = "buttons" CONF_CHANGE_RATE = "change_rate" +CONF_CLICKABLE = "clickable" CONF_CLOSE_BUTTON = "close_button" CONF_COLOR_END = "color_end" +CONF_COLOR_PICKER = "color_picker" 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" @@ -702,6 +746,7 @@ CONF_FLEX_ALIGN_CROSS = "flex_align_cross" CONF_FLEX_ALIGN_TRACK = "flex_align_track" CONF_FLEX_GROW = "flex_grow" CONF_FREEZE = "freeze" +CONF_CHECK_ARGS = "check_args" CONF_DARK_MODE = "dark_mode" CONF_FULL_REFRESH = "full_refresh" CONF_GRADIENTS = "gradients" @@ -744,7 +789,6 @@ CONF_ON_PAUSE = "on_pause" CONF_ON_PORTRAIT = "on_portrait" CONF_ON_RESUME = "on_resume" CONF_ON_SELECT = "on_select" -CONF_ON_STOP = "on_stop" CONF_OPA = "opa" CONF_NEXT = "next" CONF_PAD_ROW = "pad_row" @@ -770,6 +814,7 @@ CONF_SCALE = "scale" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" CONF_SCROLL_DIR = "scroll_dir" +CONF_SCROLLABLE = "scrollable" CONF_SCROLL_SNAP_X = "scroll_snap_x" CONF_SCROLL_SNAP_Y = "scroll_snap_y" CONF_SELECTED_INDEX = "selected_index" @@ -841,7 +886,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 @@ -851,9 +896,23 @@ 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", + "RGB565", "RGB565_SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "ARGB8888_PREMULTIPLIED", "L8", "AL88", + "A8", "I1", ) LV_DEFINES = ( diff --git a/esphome/components/lvgl/encoders.py b/esphome/components/lvgl/encoders.py index e6527bbc9b..30480eacc7 100644 --- a/esphome/components/lvgl/encoders.py +++ b/esphome/components/lvgl/encoders.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.binary_sensor import BinarySensor +from esphome.components.rotary_encoder import DOMAIN as ROTARY_ENCODER_DOMAIN from esphome.components.rotary_encoder.sensor import RotaryEncoderSensor import esphome.config_validation as cv from esphome.const import CONF_GROUP, CONF_ID, CONF_SENSOR @@ -27,7 +28,7 @@ ENCODERS_CONFIG = cv.ensure_list( cv.Required(CONF_SENSOR): cv.Any( cv.All( cv.use_id(RotaryEncoderSensor), - cv.requires_component("rotary_encoder"), + cv.requires_component(ROTARY_ENCODER_DOMAIN), ), cv.Schema( { diff --git a/esphome/components/lvgl/gradient.py b/esphome/components/lvgl/gradient.py index 8db183fabe..df62e30f8a 100644 --- a/esphome/components/lvgl/gradient.py +++ b/esphome/components/lvgl/gradient.py @@ -13,6 +13,7 @@ from esphome.core import ID from esphome.cpp_generator import MockObj from .defines import ( + CONF_COLOR_PICKER, CONF_END_ANGLE, CONF_GRADIENTS, CONF_OPA, @@ -22,6 +23,7 @@ from .defines import ( add_define, add_lv_use, add_warning, + get_lv_uses, ) from .lv_validation import ( lv_angle_degrees, @@ -164,7 +166,7 @@ GRADIENT_SCHEMA = cv.ensure_list( async def gradients_to_code(config): add_lv_use("gradient") - max_stops = 2 + max_stops = 7 if CONF_COLOR_PICKER in get_lv_uses() else 2 if any(CONF_DITHER in x for x in config.get(CONF_GRADIENTS, ())): add_warning( "The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored" 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 6f86e49e51..d8a96de639 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -3,18 +3,17 @@ 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.font import Font +from esphome.components.color import ( + CONF_HEX, + DOMAIN as COLOR_DOMAIN, + ColorStruct, + from_rgbw, +) +from esphome.components.const.css_colors import CSS_COLORS +from esphome.components.font import DOMAIN as FONT_DOMAIN, Font from esphome.components.image import Image_ import esphome.config_validation as cv -from esphome.const import ( - CONF_ARGS, - CONF_COLOR, - CONF_FORMAT, - CONF_ID, - CONF_TIME, - CONF_VALUE, -) +from esphome.const import CONF_ARGS, CONF_FORMAT, CONF_ID, CONF_TIME, CONF_VALUE from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj, StaticCastExpression, call_lambda from esphome.cpp_types import ESPTime, int32, uint32 @@ -64,174 +63,23 @@ 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): - cval = [x for x in CORE.config[CONF_COLOR] if x[CONF_ID] == value][0] + cval = [x for x in CORE.config[COLOR_DOMAIN] if x[CONF_ID] == value][0] if CONF_HEX in cval: r, g, b = cval[CONF_HEX] else: @@ -254,8 +102,8 @@ class LvColor(LValidator): ) def __getattr__(self, item): - if item in COLOR_NAMES: - return color_retmapper(COLOR_NAMES[item]) + if item in CSS_COLORS: + return color_retmapper(CSS_COLORS[item]) raise AttributeError(item) @@ -307,10 +155,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): @@ -585,7 +435,7 @@ class LvFont(LValidator): add_lv_use("font") fontval = cv.use_id(Font)(value) get_esphome_fonts_used().add(fontval) - return cv.requires_component("font")(fontval) + return cv.requires_component(FONT_DOMAIN)(fontval) # Use font::Font* as return type for lambdas returning ESPHome fonts # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* 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..0b375404d7 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -6,6 +6,7 @@ #include "core/lv_global.h" #include "core/lv_obj_class_private.h" +#include "core/lv_obj_private.h" #include @@ -13,7 +14,7 @@ static void *lv_alloc_draw_buf(size_t size, bool internal); static void *draw_buf_alloc_cb(size_t size, lv_color_format_t color_format) { return lv_alloc_draw_buf(size, false); }; namespace esphome::lvgl { -static const char *const TAG = "lvgl"; +ESPHOME_LOG_TAG(TAG, "lvgl"); static const size_t MIN_BUFFER_FRAC = 8; // buffer must be at least 1/8 of the display size static const size_t MIN_BUFFER_SIZE = 2048; // Sensible minimum buffer size @@ -225,6 +226,36 @@ void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_ev add_event_cb(obj, callback, event3, user_data); } +void LvglComponent::lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value) { + // LVGL 9.6 keeps these flags in spec_attr->user_flags, in the same bits as its deprecated lv_obj_add_flag() + uint8_t mask; + switch (flag) { + case LV_OBJ_FLAG_LAYOUT_2: + mask = 1 << 1; + break; + case LV_OBJ_FLAG_WIDGET_1: + mask = 1 << 2; + break; + case LV_OBJ_FLAG_WIDGET_2: + mask = 1 << 3; + break; + default: + return; + } + if (value) { + lv_obj_spec_attr_t *spec_attr = lv_obj_allocate_spec_attr(obj); + if (spec_attr == nullptr) + return; + spec_attr->user_flags |= mask; + } else if (obj->spec_attr != nullptr) { + obj->spec_attr->user_flags &= ~mask; + } + if (flag == LV_OBJ_FLAG_LAYOUT_2) { + lv_obj_mark_layout_as_dirty(lv_obj_get_parent(obj)); + lv_obj_mark_layout_as_dirty(obj); + } +} + void LvglComponent::add_page(LvPageType *page) { this->pages_.push_back(page); page->set_parent(this); @@ -822,7 +853,7 @@ void LvglComponent::setup() { } this->draw_buf_ = static_cast(buffer); this->set_resolution_(); - lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_RGB565); + lv_display_set_color_format(this->disp_, LV_COLOR_FORMAT_DEFAULT); lv_display_set_flush_cb(this->disp_, static_flush_cb); lv_display_set_user_data(this->disp_, this); lv_display_add_event_cb(this->disp_, rounder_cb, LV_EVENT_INVALIDATE_AREA, this); @@ -994,6 +1025,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_set_clickable(obj, false); LV_UNUSED(class_p); } @@ -1036,7 +1068,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 +1084,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/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 8b7397c4cd..4da8a6a1a3 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -269,6 +269,9 @@ class LvglComponent final : public PollingComponent { static void add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3, void *user_data = nullptr); + // Set or clear LV_OBJ_FLAG_LAYOUT_2, LV_OBJ_FLAG_WIDGET_1 or LV_OBJ_FLAG_WIDGET_2, which have no setter in LVGL 9.6 + static void lv_obj_set_custom_flag(lv_obj_t *obj, lv_obj_flag_t flag, bool value); + // change the state of a widget and fire an event if changed (only needed for CHECKED) static void lv_obj_set_state_value(lv_obj_t *obj, lv_state_t state, bool value) { diff --git a/esphome/components/lvgl/number/__init__.py b/esphome/components/lvgl/number/__init__.py index be51963ba1..b23e77df22 100644 --- a/esphome/components/lvgl/number/__init__.py +++ b/esphome/components/lvgl/number/__init__.py @@ -1,16 +1,10 @@ import esphome.codegen as cg from esphome.components import number import esphome.config_validation as cv -from esphome.const import CONF_ON_RELEASE, CONF_RESTORE_VALUE +from esphome.const import CONF_RESTORE_VALUE from esphome.cpp_generator import MockObj -from ..defines import ( - CONF_ANIMATED, - CONF_TRIGGER, - CONF_UPDATE_ON_RELEASE, - CONF_WIDGET, - LOGGER, -) +from ..defines import CONF_ANIMATED, CONF_TRIGGER, CONF_UPDATE_ON_RELEASE, CONF_WIDGET from ..lv_validation import animated from ..lvcode import ( EVENT_ARG, @@ -22,7 +16,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) @@ -31,23 +25,20 @@ CONFIG_SCHEMA = number.number_schema(LVGLNumber).extend( cv.Required(CONF_WIDGET): cv.use_id(LvNumber), **VALUE_TRIGGER_SCHEMA, cv.Optional(CONF_ANIMATED, default=True): animated, - cv.Optional(CONF_UPDATE_ON_RELEASE): cv.boolean, + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_UPDATE_ON_RELEASE): cv.invalid( + "The 'update_on_release' option has been removed in ESPHome 2026.11.0.\n" + "Use 'trigger: on_release' instead." + ), cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean, } ) async def to_code(config): - trigger = config[CONF_TRIGGER] - if CONF_UPDATE_ON_RELEASE in config: - LOGGER.warning( - "Option 'update_on_release' is deprecated and will be removed in 2026.11.0 - use 'trigger: on_release' instead" - ) - if config[CONF_UPDATE_ON_RELEASE]: - 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: @@ -71,6 +62,6 @@ async def to_code(config): lvgl_static.add_event_cb( widget.obj, await event.get_lambda(), - *TRIGGER_EVENT_MAP[trigger], + *TRIGGER_EVENT_MAP[config[CONF_TRIGGER]], ) ) diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index a546d9f183..2d879e8d43 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -4,6 +4,7 @@ from typing import Any from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation +from esphome.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from esphome.components.mapping import mapping_class from esphome.components.time import RealTimeClock from esphome.config_validation import prepend_path @@ -148,7 +149,8 @@ PRESS_TIME = cv.All( ENCODER_SCHEMA = cv.Schema( { cv.GenerateID(): cv.All( - cv.declare_id(LVEncoderListener), cv.requires_component("binary_sensor") + cv.declare_id(LVEncoderListener), + cv.requires_component(BINARY_SENSOR_DOMAIN), ), cv.Optional(CONF_GROUP): cv.declare_id(lv_group_t), cv.Optional(df.CONF_INITIAL_FOCUS): cv.All( @@ -389,7 +391,6 @@ SET_STATE_SCHEMA = cv.Schema( ) # Setting object flags FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS}) -FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of) VALUE_TRIGGER_SCHEMA = { cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of( @@ -670,11 +671,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..8231cd3b84 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, + add_warning, 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, misplaced_obj_properties def has_style_props(config) -> bool: @@ -72,9 +77,20 @@ class LVStyle: return cls.named_styles.setdefault(id_name, LVStyle(id_name)) +def _warn_misplaced_obj_properties(config: dict, owner: str) -> None: + """Warn about widget properties in a style or theme, where they have no effect.""" + for where, prop in misplaced_obj_properties(config, include_top=True): + place = f"{owner} under '{where}'" if where else owner + add_warning( + f"'{prop}' applies to a widget, not to a style, so setting it in {place} has " + f"no effect. Set it on each widget instead." + ) + + async def styles_to_code(config): """Convert styles to C__ code.""" for style in config.get(CONF_STYLE_DEFINITIONS, ()): + _warn_misplaced_obj_properties(style, f"style '{style[CONF_ID].id}'") await create_style(style[CONF_ID].id, style) @@ -89,7 +105,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 +116,65 @@ 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, str, str]]: + """Return a list of (style variable, part, state) 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], 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 {} + for w_name, w_theme in theme.items(): + if isinstance(w_theme, dict): + _warn_misplaced_obj_properties(w_theme, f"the theme for '{w_name}'") 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 +183,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/touchscreens.py b/esphome/components/lvgl/touchscreens.py index 0bb5715439..6b645da3aa 100644 --- a/esphome/components/lvgl/touchscreens.py +++ b/esphome/components/lvgl/touchscreens.py @@ -1,5 +1,9 @@ import esphome.codegen as cg -from esphome.components.touchscreen import CONF_TOUCHSCREEN_ID, Touchscreen +from esphome.components.touchscreen import ( + CONF_TOUCHSCREEN_ID, + DOMAIN as TOUCHSCREEN_DOMAIN, + Touchscreen, +) import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.core import CORE @@ -17,7 +21,7 @@ CONF_TOUCHSCREEN = "touchscreen" TOUCHSCREENS_CONFIG = cv.maybe_simple_value( { cv.Required(CONF_TOUCHSCREEN_ID): cv.All( - cv.use_id(Touchscreen), cv.requires_component(CONF_TOUCHSCREEN) + cv.use_id(Touchscreen), cv.requires_component(TOUCHSCREEN_DOMAIN) ), cv.Optional(CONF_LONG_PRESS_TIME, default="400ms"): PRESS_TIME, cv.Optional(CONF_LONG_PRESS_REPEAT_TIME, default="100ms"): PRESS_TIME, @@ -29,7 +33,7 @@ TOUCHSCREENS_CONFIG = cv.maybe_simple_value( def touchscreen_schema(config): value = cv.ensure_list(TOUCHSCREENS_CONFIG)(config) - if value or CONF_TOUCHSCREEN not in CORE.loaded_integrations: + if value or TOUCHSCREEN_DOMAIN not in CORE.loaded_integrations: return value return [TOUCHSCREENS_CONFIG(config)] diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index 56dcf81a79..cd118595f1 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -17,6 +17,8 @@ from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, + CONF_CLICKABLE, + CONF_SCROLLABLE, DIRECTIONS, LV_DISPLAY_EVENT_MAP, LV_DISPLAY_EVENT_TRIGGERS, @@ -72,7 +74,7 @@ async def generate_triggers(): event: conf for event, conf in config.items() if event in all_triggers }.items(): conf = conf[0] - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger(conf, w, event) for event, conf in { @@ -82,7 +84,7 @@ async def generate_triggers(): dir = event[9:].upper() dir = {"UP": "TOP", "DOWN": "BOTTOM"}.get(dir, dir) dir = DIRECTIONS.mapper(dir) - w.clear_flag("LV_OBJ_FLAG_SCROLLABLE") + w.set_flag(CONF_SCROLLABLE, False) selected = literal( f"lv_indev_get_gesture_dir(lv_indev_active()) == {dir}" ) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index a524fe761f..210314103e 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,10 +1,11 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Iterator +from contextlib import ExitStack import sys from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.automation import register_action -from esphome.config_validation import Invalid, Schema +from esphome.config_validation import Schema from esphome.const import ( CONF_DEFAULT, CONF_GROUP, @@ -21,6 +22,7 @@ from esphome.schema_extractors import EnableSchemaExtraction from esphome.types import Expression from ..defines import ( + CONF_DEBUG_OUTLINE, CONF_FLEX_ALIGN_CROSS, CONF_FLEX_ALIGN_MAIN, CONF_FLEX_ALIGN_TRACK, @@ -36,22 +38,25 @@ from ..defines import ( CONF_SCALE, CONF_STYLES, CONF_WIDGETS, + CUSTOM_FLAGS, + FLAG_SETTER_ALIASES, LOGGER, OBJ_FLAGS, PARTS, STATES, LValidator, add_lv_use, + add_warning, + 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 ( - LvConditional, + LocalVariable, add_line_marks, lv, lv_add, @@ -180,7 +185,7 @@ class WidgetType: wid = config[CONF_ID] add_line_marks(wid) if self.is_compound(): - var = cg.new_Pvariable(wid) + var = cg.new_Pvariable(wid, *await self.get_ctor_args(config)) lv_add(var.set_obj(creator)) await self.on_create(var.obj, config) else: @@ -191,17 +196,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 @@ -218,6 +232,37 @@ class WidgetType: :param config: Its configuration """ + async def get_ctor_args(self, config: dict) -> list: + """ + Get extra arguments for a compound widget's constructor. Anything the widget needs + before it builds its parts belongs here, since a setter would be called too late. + :param config: Its configuration + :return: + """ + return [] + + def obj_targets(self, w: "Widget", prop: str) -> list["Widget"]: + """ + Get the widgets one object property, as opposed to a style, applies to. A widget + built out of other widgets can point a property at the ones it really concerns, such + as sending a touch margin to the parts that are actually pressed. + :param w: The widget being configured + :param prop: The property name, one of OBJ_PROPERTIES + :return: + """ + return [w] + + def part_targets(self, w: "Widget", part: str) -> list[tuple["Widget", str]]: + """ + Get the widgets a part's styles should be applied to, each paired with the part to + use on it. A widget built out of other widgets can point a part at those, so that + styling it works the same as it does for a plain widget. + :param w: The widget being styled + :param part: The configured part name + :return: + """ + return [(w, part)] + def get_uses(self) -> tuple: """ Get a list of other widgets used by this one @@ -257,17 +302,26 @@ 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, part, state in get_widget_theme_styles(w.type.name): + for target, target_part in w.type.part_targets(w, part): + target.add_style(style, get_part_state_selector(target_part, 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: @@ -276,7 +330,9 @@ class Widget: This class has a lot of methods. Adding any more runs foul of lint checks ("too many public methods"). """ - def __init__(self, var, wtype: WidgetType, config: dict = None): + def __init__( + self, var: MockObj | ID, wtype: WidgetType, config: dict | None = None + ) -> None: self.var = var self.type = wtype self.config = config @@ -295,41 +351,50 @@ class Widget: self.parent = None @staticmethod - def create(name, var, wtype: WidgetType, config: dict = None): + def create( + name: ID | str, var: MockObj, wtype: WidgetType, config: dict | None = None + ) -> "Widget": w = Widget(var, wtype, config) get_widget_map()[name] = w return w - def set_state(self, state: MockObj, value: bool | Expression): + def set_state(self, state: MockObj, value: bool | Expression) -> None: lv_add(lvgl_static.lv_obj_set_state_value(self.obj, state, value)) - def has_state(self, state: MockObj): + def has_state(self, state: MockObj) -> MockObj: return lv_expr.obj_has_state(self.obj, state) - def is_pressed(self): + def is_pressed(self) -> MockObj: return self.has_state(LV_STATE.PRESSED) - def is_checked(self): + def is_checked(self) -> MockObj: return self.has_state(LV_STATE.CHECKED) - def add_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - return lv_obj.add_flag(self.obj, literal(flag)) + def set_flag(self, flag: str, value: bool | Expression) -> None: + """ + Set or clear an object flag, given as the lower case name without the LV_OBJ_FLAG_ prefix + """ + flag = FLAG_SETTER_ALIASES.get(flag, flag) + if flag in CUSTOM_FLAGS: + lv_add( + lvgl_static.lv_obj_set_custom_flag( + self.obj, literal(f"LV_OBJ_FLAG_{flag.upper()}"), value + ) + ) + else: + lv_obj.call(f"set_{flag}", self.obj, value) - def clear_flag(self, flag): - if "|" in flag: - flag = f"(lv_obj_flag_t)({flag})" - 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})" + def add_style(self, style_id: ID, state: MockObj | str = LV_STATE.DEFAULT) -> None: lv_obj.add_style(self.obj, MockObj(style_id), literal(state)) async def set_property( - self, prop, value, animated: bool = None, lv_name=None, processor=None - ): + self, + prop: str, + value: Any, + animated: bool | None = None, + lv_name: str | None = None, + processor: LValidator | Callable[[Any], Awaitable[Any]] | None = None, + ) -> None: """ Set a property of the widget. :param prop: The property name @@ -370,11 +435,13 @@ class Widget: literal("LV_ANIM_ON" if animated else "LV_ANIM_OFF"), ) - def get_property(self, prop, ltype=None): + def get_property(self, prop: str, ltype: str | None = None) -> cg.RawExpression: ltype = ltype or self.__type_base() return cg.RawExpression(f"lv_{ltype}_get_{prop}({self.obj})") - def set_style(self, prop: str, value, state=LV_STATE.DEFAULT): + def set_style( + self, prop: str, value: Any, state: MockObj | str = LV_STATE.DEFAULT + ) -> None: if value is None: return get_styles_used().add(prop) @@ -382,22 +449,22 @@ class Widget: value = literal(value) lv.call(f"obj_set_style_{prop}", self.obj, value, state) - def __type_base(self): + def __type_base(self) -> str: wtype = self.type.w_type base = str(wtype) if base.startswith("Lv"): return f"{wtype}".removeprefix("Lv").removesuffix("Type").lower() return f"{wtype}".removeprefix("lv_").removesuffix("_t") - def __str__(self): + def __str__(self) -> str: return f"({self.var}, {self.type})" - def get_args(self): + def get_args(self) -> list[tuple[MockObj, str]]: if isinstance(self.type.w_type, LvType): return self.type.w_type.args.copy() return [(lv_obj_t_ptr, "obj")] - def get_value(self): + def get_value(self) -> Expression: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -405,7 +472,7 @@ class Widget: return result return self.obj - def get_values(self): + def get_values(self) -> list[Expression]: if isinstance(self.type.w_type, LvType): result = self.type.w_type.value(self) if isinstance(result, list): @@ -413,13 +480,13 @@ class Widget: return [result] return [self.obj] - def get_number_value(self): + def get_number_value(self) -> Expression: value = self.type.mock_obj.get_value(self.obj) if self.scale == 1.0: return value return value / float(self.scale) - def is_selected(self): + def is_selected(self) -> MockObj | None: """ Overridable property to determine if the widget is selected. Will be None except for matrix buttons @@ -427,7 +494,7 @@ class Widget: """ return - def get_scale(self): + def get_scale(self) -> float: return self.type.get_scale(self.config) @@ -457,10 +524,6 @@ def get_widget_generator(wid): while True: if obj := widget_map.get(wid): return obj - if get_widgets_completed(): - raise Invalid( - f"Widget {wid} not found, yet all widgets should be defined by now" - ) yield @@ -470,19 +533,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 [] @@ -524,6 +574,38 @@ def collect_states(config): return states +def misplaced_obj_properties( + config: dict, include_top: bool = False +) -> Iterator[tuple[str, str]]: + """ + Find object properties set where LVGL has no way to apply them. They belong to a widget + as a whole, taking neither a part nor a state, so anywhere else they are quietly dropped. + :param config: A widget, style or theme configuration + :param include_top: Also report the top level, as for a style, which never holds them + :return: Each place one was found, as a part/state path ("" for the top level), paired + with the property name + """ + from ..schemas import OBJ_PROPERTIES + + def scan(sub: Any, where: str) -> Iterator[tuple[str, str]]: + if isinstance(sub, dict): + for prop in sorted(OBJ_PROPERTIES.intersection(sub)): + yield where, prop + + if include_top: + yield from scan(config, "") + for state in STATES: + if state in config: + yield from scan(config[state], state) + for part in PARTS: + if part not in config: + continue + yield from scan(config[part], part) + for state in STATES: + if state in config[part]: + yield from scan(config[part][state], f"{part}/{state}") + + def collect_parts(config): """ Collect properties and states for all widget parts @@ -595,6 +677,13 @@ def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None: w.set_style("flex_track_place", literal(track)) +def _widget_name(w: Widget) -> str: + """The id a widget was given, for naming it in a warning.""" + if isinstance(w.config, dict) and (wid := w.config.get(CONF_ID)) is not None: + return str(wid) + return str(w.var) + + async def set_obj_properties(w: Widget, config): """Generate a list of C++ statements to apply properties to an lv_obj_t""" @@ -613,53 +702,54 @@ async def set_obj_properties(w: Widget, config): else: base_name = None _set_layout_options(w, layout, base_name) + for where, prop in misplaced_obj_properties(config): + add_warning( + f"'{prop}' applies to a widget as a whole, not to one part or state of it, so " + f"setting it under '{where}' of widget '{_widget_name(w)}' has no effect. Set it " + f"on the widget itself instead." + ) parts = collect_parts(config) for part, states in parts.items(): - part = "LV_PART_" + part.upper() + targets = w.type.part_targets(w, part) 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)) - for style_id in props.get(CONF_STYLES, ()): - w.add_style(style_id, lv_state) - for prop, value in { - k: v for k, v in props.items() if k in ALL_STYLES - }.items(): - if isinstance(ALL_STYLES[prop], LValidator): - value = await ALL_STYLES[prop].process(value) - prop_r = remap_property(prop) - w.set_style(prop_r, value, lv_state) + style_ids = props.get(CONF_STYLES, ()) + # Each value is worked out once and then given to every target. A widget built + # out of others has one target per part, and a lambda is written out in full + # wherever it appears, so with more than one target it goes into a variable + # rather than being repeated. The blocks holding those variables stay open + # until every target has been dealt with. + with ExitStack() as stack: + values = [] + for prop, value in { + k: v for k, v in props.items() if k in ALL_STYLES + }.items(): + validator = ALL_STYLES[prop] + if isinstance(validator, LValidator): + is_lambda = isinstance(value, cv.Lambda) + value = await validator.process(value) + if is_lambda and len(targets) > 1: + value = stack.enter_context( + LocalVariable(prop, validator.rtype, value, modifier="") + ) + values.append((remap_property(prop), value)) + for target, target_part in targets: + lv_state = get_part_state_selector(target_part, state) + for style_id in style_ids: + target.add_style(style_id, lv_state) + for prop_r, value in values: + target.set_style(prop_r, value, lv_state) if group := config.get(CONF_GROUP): group = await cg.get_variable(group) lv.group_add_obj(group, w.obj) props = parts[CONF_MAIN][CONF_DEFAULT] - lambs = {} - flag_set = set() - flag_clr = set() - for prop, value in {k: v for k, v in props.items() if k in OBJ_FLAGS}.items(): + for flag, value in props.items(): + if flag not in OBJ_FLAGS: + continue if isinstance(value, cv.Lambda): - lambs[prop] = value - elif value: - flag_set.add(prop) - else: - flag_clr.add(prop) - if flag_set: - adds = join_enums(flag_set, "LV_OBJ_FLAG_") - w.add_flag(adds) - if flag_clr: - clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") - w.clear_flag(clrs) - for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) - flag = f"LV_OBJ_FLAG_{key.upper()}" - with LvConditional(call_lambda(lamb)) as cond: - w.add_flag(flag) - cond.else_() - w.clear_flag(flag) + value = call_lambda( + await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) + ) + w.set_flag(flag, value) for key, value in config.get(CONF_STATE, {}).items(): if isinstance(value, cv.Lambda): @@ -670,7 +760,8 @@ async def set_obj_properties(w: Widget, config): w.set_state(state, value) for property in OBJ_PROPERTIES: - await w.set_property(property, config, lv_name="obj") + for target in w.type.obj_targets(w, property): + await target.set_property(property, config, lv_name="obj") async def add_widgets(parent: Widget, config: dict): diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index ac993cc382..4739412e09 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -13,12 +13,12 @@ from ..defines import ( ARC_MODES, CONF_ADJUSTABLE, CONF_CHANGE_RATE, + CONF_CLICKABLE, CONF_END_ANGLE, CONF_INDICATOR, CONF_KNOB, CONF_MAIN, CONF_START_ANGLE, - LV_OBJ_FLAG, LV_PART, ) from ..lv_validation import ( @@ -85,7 +85,7 @@ class ArcType(NumberType): if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: lv_obj.remove_style(w.obj, nullptr, LV_PART.KNOB) - w.clear_flag(LV_OBJ_FLAG.CLICKABLE) + w.set_flag(CONF_CLICKABLE, False) elif CONF_GROUP not in config: # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) 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..9dd5e84419 100644 --- a/esphome/components/lvgl/widgets/canvas.py +++ b/esphome/components/lvgl/widgets/canvas.py @@ -99,7 +99,7 @@ class CanvasType(WidgetType): if config[CONF_TRANSPARENT]: color_format = "LV_COLOR_FORMAT_ARGB8888" else: - color_format = "LV_COLOR_FORMAT_NATIVE" + color_format = "LV_COLOR_FORMAT_RGB565" # LVGL 9.4: LV_CANVAS_BUF_SIZE(width, height, bits_per_pixel, stride) # stride is 0 for default (width * bytes_per_pixel) @@ -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/color_picker.py b/esphome/components/lvgl/widgets/color_picker.py new file mode 100644 index 0000000000..4f501ba0ee --- /dev/null +++ b/esphome/components/lvgl/widgets/color_picker.py @@ -0,0 +1,240 @@ +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_COLOR, CONF_HEIGHT, CONF_ITEMS, CONF_WIDTH +from esphome.core import CORE + +from ..defines import ( + CONF_COLOR_PICKER, + CONF_EXT_CLICK_AREA, + CONF_KNOB, + CONF_MAIN, + CONF_STYLES, + CONF_THEME, + DOMAIN, + add_define, + add_lv_use, + literal, +) +from ..lv_validation import lv_color, size +from ..lvcode import lv_add +from ..types import LvCompound, LvType +from . import Widget, WidgetType +from .label import CONF_LABEL +from .lv_bar import CONF_BAR +from .slider import CONF_SLIDER, slider_spec + +# esphome::Color, not lv_color_t: it carries the same value but exposes the components as +# `x.r`/`x.g`/`x.b`, and converts to lv_color_t on its own where LVGL needs one. +Color = cg.esphome_ns.class_("Color") + +lv_color_picker_t = LvType( + "LvColorPickerType", + parents=(LvCompound,), + largs=[(Color, "x")], + lvalue=lambda w: w.var.get_color(), + has_on_value=True, +) + +# Makes `lvgl.widget.update` with a `color:` report the change through `on_value`, the same +# as setting the value of any other widget does. +lv_color_picker_t.value_property = CONF_COLOR + +CONF_SLIDERS = "sliders" +CONF_WHEEL = "wheel" + +CONF_BG_COLOR = "bg_color" + +# The sliders the widget can be built from. The order must match +# LvColorPickerType::SliderIndex, since it decides which bit of the mask each one gets. +SLIDER_NAMES = ( + "hue", + "saturation", + "brightness", + "red", + "green", + "blue", +) + +# Shorthand for the two sets of sliders that are usually wanted together. +SLIDER_GROUPS = { + "hsv": ("hue", "saturation", "brightness"), + "hsb": ("hue", "saturation", "brightness"), + "rgb": ("red", "green", "blue"), +} + +# The wheel draws hue and saturation itself, and the layout drops the RGB sliders to make +# room for it, leaving brightness as the only slider that still fits. +WHEEL_EXCLUDED_SLIDERS = ("hue", "saturation", "red", "green", "blue") + + +def validate_sliders(value: Any) -> list[str]: + """Expand any group names and drop repeats, keeping the widget's own slider order.""" + value = cv.ensure_list(cv.one_of(*SLIDER_NAMES, *SLIDER_GROUPS, lower=True))(value) + chosen = {name for item in value for name in SLIDER_GROUPS.get(item, (item,))} + if not chosen: + raise cv.Invalid("At least one slider is required") + return [name for name in SLIDER_NAMES if name in chosen] + + +COLOR_PICKER_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_COLOR): lv_color, + } +) + +COLOR_PICKER_SCHEMA = COLOR_PICKER_MODIFY_SCHEMA.extend( + { + cv.Optional(CONF_WIDTH, default="SIZE_CONTENT"): size, + cv.Optional(CONF_HEIGHT): cv.invalid("Height will be set to the same as width"), + cv.Optional(CONF_SLIDERS, default=list(SLIDER_NAMES)): validate_sliders, + cv.Optional(CONF_WHEEL, default=False): cv.boolean, + } +) + + +# Starting colour for a picker with no way to change saturation, which would otherwise be stuck +# at the grey it starts with. Saturation changes with its own slider, the wheel, or any of the +# red, green and blue sliders, so only these two sliders on their own leave it fixed. +SATURATED_DEFAULT_COLOR = 0xFF0000 +HUE_AND_BRIGHTNESS = {"hue", "brightness"} + + +def _knob_color_is_set(knob: dict) -> bool: + """Whether a knob is given a background colour, directly or through named styles.""" + return CONF_BG_COLOR in knob or bool(knob.get(CONF_STYLES)) + + +def _theme_sets_knob_color() -> bool: + """Whether any LVGL theme gives the color picker's knobs a background colour.""" + configs = CORE.config.get(DOMAIN) or [] + if isinstance(configs, dict): + configs = [configs] + return any( + _knob_color_is_set( + ((config.get(CONF_THEME) or {}).get(CONF_COLOR_PICKER) or {}).get( + CONF_KNOB, {} + ) + ) + for config in configs + ) + + +def _sliders(config: dict) -> list[str]: + """Get the sliders a widget was created with, in the widget's own order.""" + return config.get(CONF_SLIDERS) or list(SLIDER_NAMES) + + +class ColorPickerType(WidgetType): + def __init__(self) -> None: + super().__init__( + CONF_COLOR_PICKER, + lv_color_picker_t, + parts=(CONF_MAIN, CONF_ITEMS, CONF_KNOB), + schema=COLOR_PICKER_SCHEMA, + modify_schema=COLOR_PICKER_MODIFY_SCHEMA, + lv_name="obj", + ) + + def validate(self, value: Any) -> Any: + add_lv_use(CONF_COLOR_PICKER) + value = dict(value) + if cv.boolean(value.get(CONF_WHEEL, False)): + # The wheel is a conical hue gradient under a radial white-to-transparent one; + # LVGL's software renderer only draws either kind when this is enabled. + add_define("LV_USE_DRAW_SW_COMPLEX_GRADIENTS") + if CONF_SLIDERS in value: + sliders = validate_sliders(value[CONF_SLIDERS]) + if any(name in sliders for name in WHEEL_EXCLUDED_SLIDERS): + raise cv.Invalid( + "the color wheel already shows hue and saturation, and replaces " + "the red, green and blue sliders with it; remove them from " + "'sliders' or set 'wheel: false'" + ) + else: + # Otherwise the schema default would build every slider including these. + value[CONF_SLIDERS] = [ + name for name in SLIDER_NAMES if name not in WHEEL_EXCLUDED_SLIDERS + ] + return super().validate(value) + + async def get_ctor_args(self, config: dict) -> list: + # The layout is worked out from the sliders, so they are needed before the widget is + # built rather than set afterwards. + return [ + literal( + " | ".join( + f"{lv_color_picker_t}::SLIDER_FLAG_{name.upper()}" + for name in _sliders(config) + ) + ), + config[CONF_WHEEL], + ] + + @staticmethod + def _sliders_of(w: Widget) -> list[Widget]: + """Wrap each slider the widget was created with, so it can be configured directly.""" + return [ + Widget( + w.var.get_slider( + literal(f"{lv_color_picker_t}::SLIDER_{name.upper()}") + ), + slider_spec, + ) + for name in _sliders(w.config or {}) + ] + + def obj_targets(self, w: Widget, prop: str) -> list[Widget]: + # Nothing is ever pressed on its own object, so the touch margin belongs on the + # sliders. Everything else, scrolling in particular, is a matter for the container + # holding them and so stays where it is. + if prop == CONF_EXT_CLICK_AREA: + return self._sliders_of(w) + return [w] + + def part_targets(self, w: Widget, part: str) -> list[tuple[Widget, str]]: + # The widget is made of sliders, so `items` and `knob` are the main and knob parts of + # each of those. Its own object is a plain container with neither. Only the sliders + # the widget was created with exist, so only those are styled. + if part == CONF_MAIN: + return [(w, part)] + target_part = CONF_MAIN if part == CONF_ITEMS else part + return [(slider, target_part) for slider in self._sliders_of(w)] + + async def to_code(self, w: Widget, config: dict) -> None: + # Only the configuration that creates the widget has `sliders`; an update does not. + creating = CONF_SLIDERS in config + if creating and config[CONF_WHEEL]: + # Without a wheel anywhere, its code and gradients are left out of the build. + cg.add_define("USE_LVGL_COLOR_PICKER_WHEEL") + color = config.get(CONF_COLOR) + if ( + color is None + and creating + and not config[CONF_WHEEL] + and set(config[CONF_SLIDERS]) <= HUE_AND_BRIGHTNESS + ): + color = SATURATED_DEFAULT_COLOR + # A hex colour validates to a plain int, so black must not be mistaken for absent. + if color is not None: + lv_add(w.var.set_color(await lv_color.process(color))) + # Each knob is normally tinted with the colour its own slider shows. That is applied + # as a local style, which outranks any style the knob has been given, so a background + # colour configured for the knob, here or in the theme, would never show; tinting + # gives way to it instead. + if _knob_color_is_set(config.get(CONF_KNOB, {})) or ( + creating and _theme_sets_knob_color() + ): + lv_add(w.var.set_tint_knobs(False)) + # The widget is square, so the height simply follows the configured width. + # SIZE_CONTENT works too: the widget reports a size based on its text font. + # Width is required when creating the widget but absent when updating one. + if (width := config.get(CONF_WIDTH)) is not None: + w.set_style(CONF_HEIGHT, await size.process(width), 0) + + def get_uses(self) -> tuple[str, ...]: + return ("flex", CONF_SLIDER, CONF_BAR, CONF_LABEL) + + +color_picker_spec = ColorPickerType() 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..98e1838f0f 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,14 +14,13 @@ 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 from ..automation import action_to_code from ..defines import ( CONF_ALIGN_TO, + CONF_CLICKABLE, CONF_MAIN, CONF_PAD_ROW, CONF_SCROLLBAR, @@ -31,6 +29,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 +56,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 +64,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 +75,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 +85,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 +171,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 +200,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 +210,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 +345,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 +360,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 +418,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, ()): @@ -468,7 +442,7 @@ async def _wire_dynamic_triggers(w: Widget, config: dict) -> None: for event, conf in { event: conf for event, conf in config.items() if event in LV_EVENT_TRIGGERS }.items(): - w.add_flag("LV_OBJ_FLAG_CLICKABLE") + w.set_flag(CONF_CLICKABLE, True) await add_trigger( conf[0], event_target, event, attach_obj=w.obj, user_data=user_data ) @@ -505,7 +479,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 +499,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 +510,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..a49d58dc4d 100644 --- a/esphome/components/lvgl/widgets/meter.py +++ b/esphome/components/lvgl/widgets/meter.py @@ -1,6 +1,6 @@ from esphome import automation import esphome.codegen as cg -from esphome.components.image import get_image_metadata +from esphome.components.image import DOMAIN as IMAGE_DOMAIN, get_image_metadata import esphome.config_validation as cv from esphome.const import ( CONF_COLOR, @@ -38,7 +38,6 @@ from ..defines import ( CONF_SRC, CONF_START_VALUE, CONF_TICKS, - LV_OBJ_FLAG, LV_PART, LV_SCALE_MODE, add_lv_use, @@ -214,7 +213,7 @@ INDICATOR_SCHEMA = cv.Schema( cv.GenerateID(CONF_IMAGE_ID): cv.declare_id(lv_image_t), } ), - cv.requires_component("image"), + cv.requires_component(IMAGE_DOMAIN), ), cv.Exclusive(CONF_ARC, CONF_INDICATORS): INDICATOR_ARC_SCHEMA.extend( { @@ -457,7 +456,7 @@ class MeterType(WidgetType): LV_EVENT.DRAW_TASK_ADDED, nullptr, ) - lv.obj_add_flag(scale_var, LV_OBJ_FLAG.SEND_DRAW_TASK_EVENTS) + lv_obj.set_send_draw_task_events(scale_var, True) if t == CONF_LINE: # Needle represented by a line @@ -612,9 +611,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/msgbox.py b/esphome/components/lvgl/widgets/msgbox.py index 29087009cb..44d16bf614 100644 --- a/esphome/components/lvgl/widgets/msgbox.py +++ b/esphome/components/lvgl/widgets/msgbox.py @@ -1,5 +1,5 @@ from esphome import codegen as cg, config_validation as cv -from esphome.const import CONF_BUTTON, CONF_ID, CONF_TEXT +from esphome.const import CONF_BUTTON, CONF_HIDDEN, CONF_ID, CONF_TEXT from esphome.core import ID from esphome.cpp_generator import MockObjClass from esphome.cpp_types import nullptr @@ -14,7 +14,6 @@ from ..defines import ( CONF_MSGBOXES, CONF_SRC, CONF_TITLE, - LV_OBJ_FLAG, TYPE_FLEX, add_lv_use, add_warning, @@ -141,7 +140,7 @@ async def msgbox_to_code(top_layer, conf): percent100 = await pixels_or_percent.process(1.0) lv_obj.set_size(outer, percent100, percent100) outer_widget.add_style(await OUTER_STYLE.get_var()) - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) lv.msgbox_add_title(msgbox, title) lv.msgbox_add_text(msgbox, text) lv_obj.set_style_align(msgbox, literal("LV_ALIGN_CENTER"), 0) @@ -153,7 +152,7 @@ async def msgbox_to_code(top_layer, conf): await widget_to_code(button, header_button_spec, msgbox) async with LambdaContext(EVENT_ARG, where=messagebox_id) as close_action: - outer_widget.add_flag(LV_OBJ_FLAG.HIDDEN) + outer_widget.set_flag(CONF_HIDDEN, True) if close_button: with LocalVariable( "close_btn_", lv_obj_t, lv_expr.msgbox_add_close_button(msgbox) 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/m5stack_8angle/__init__.py b/esphome/components/m5stack_8angle/__init__.py index 6404bcf64c..96b9782e81 100644 --- a/esphome/components/m5stack_8angle/__init__.py +++ b/esphome/components/m5stack_8angle/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] CODEOWNERS = ["@rnauber"] +DOMAIN = "m5stack_8angle" MULTI_CONF = True CONF_M5STACK_8ANGLE_ID = "m5stack_8angle_id" diff --git a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp index e132c54daa..65b5816e46 100644 --- a/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp +++ b/esphome/components/m5stack_8angle/light/m5stack_8angle_light.cpp @@ -4,7 +4,7 @@ namespace esphome::m5stack_8angle { -static const char *const TAG = "m5stack_8angle.light"; +ESPHOME_LOG_TAG(TAG, "m5stack_8angle.light"); void M5Stack8AngleLightOutput::setup() { RAMAllocator allocator; diff --git a/esphome/components/m5stack_8angle/m5stack_8angle.cpp b/esphome/components/m5stack_8angle/m5stack_8angle.cpp index f466fba77e..06f9b90af9 100644 --- a/esphome/components/m5stack_8angle/m5stack_8angle.cpp +++ b/esphome/components/m5stack_8angle/m5stack_8angle.cpp @@ -4,7 +4,7 @@ namespace esphome::m5stack_8angle { -static const char *const TAG = "m5stack_8angle"; +ESPHOME_LOG_TAG(TAG, "m5stack_8angle"); void M5Stack8AngleComponent::setup() { i2c::ErrorCode err; diff --git a/esphome/components/matrix_keypad/__init__.py b/esphome/components/matrix_keypad/__init__.py index 2e43eaf7e2..8e9b21fe43 100644 --- a/esphome/components/matrix_keypad/__init__.py +++ b/esphome/components/matrix_keypad/__init__.py @@ -7,6 +7,7 @@ from esphome.const import CONF_ID, CONF_ON_KEY, CONF_PIN, CONF_TRIGGER_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] +DOMAIN = "matrix_keypad" AUTO_LOAD = ["key_provider"] @@ -27,6 +28,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 +79,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..e0f19ca165 100644 --- a/esphome/components/matrix_keypad/matrix_keypad.cpp +++ b/esphome/components/matrix_keypad/matrix_keypad.cpp @@ -4,7 +4,7 @@ namespace esphome::matrix_keypad { -static const char *const TAG = "matrix_keypad"; +ESPHOME_LOG_TAG(TAG, "matrix_keypad"); void MatrixKeypad::setup() { for (auto *pin : this->rows_) { @@ -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/__init__.py b/esphome/components/max17043/__init__.py index 1db25ccdd6..ba609881f0 100644 --- a/esphome/components/max17043/__init__.py +++ b/esphome/components/max17043/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@blacknell"] +DOMAIN = "max17043" 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/max17043.cpp b/esphome/components/max17043/max17043.cpp index 8776bb5558..a15658512b 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -6,7 +6,7 @@ namespace esphome::max17043 { // MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert // Consult the datasheet at https://www.analog.com/en/products/max17043.html -static const char *const TAG = "max17043"; +ESPHOME_LOG_TAG(TAG, "max17043"); static const uint8_t MAX17043_VCELL = 0x02; static const uint8_t MAX17043_SOC = 0x04; 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/max17048/__init__.py b/esphome/components/max17048/__init__.py new file mode 100644 index 0000000000..ecd6a79dd5 --- /dev/null +++ b/esphome/components/max17048/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@B48D81EFCC"] +DOMAIN = "max17048" diff --git a/esphome/components/max17048/max17048.cpp b/esphome/components/max17048/max17048.cpp new file mode 100644 index 0000000000..67f9dbaac3 --- /dev/null +++ b/esphome/components/max17048/max17048.cpp @@ -0,0 +1,77 @@ +#include "max17048.h" +#include "esphome/core/log.h" + +namespace esphome::max17048 { + +// MAX17048/MAX17049 1 and 2 cell fuel gauges with ModelGauge +// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/MAX17048-MAX17049.pdf + +ESPHOME_LOG_TAG(TAG, "max17048"); + +static constexpr uint8_t REG_VCELL = 0x02; // cell voltage +static constexpr uint8_t REG_SOC = 0x04; // state of charge +static constexpr uint8_t REG_VERSION = 0x08; // 0x001x +static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed + +static constexpr uint16_t VERSION_MASK = 0xFFF0; +static constexpr uint16_t VERSION_FAMILY = 0x0010; + +static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f; +static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f; +static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f; + +void MAX17048Component::setup() { + uint16_t version; + if (!this->read_byte_16(REG_VERSION, &version)) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + if ((version & VERSION_MASK) != VERSION_FAMILY) { + ESP_LOGW(TAG, "Unexpected version 0x%04X", version); + } +} + +void MAX17048Component::update() { + bool ok = true; + uint16_t raw; + if (this->battery_voltage_sensor_ != nullptr) { + if (this->read_byte_16(REG_VCELL, &raw)) { + this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB); + } else { + ok = false; + } + } + if (this->battery_level_sensor_ != nullptr) { + if (this->read_byte_16(REG_SOC, &raw)) { + this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB); + } else { + ok = false; + } + } + if (this->rate_sensor_ != nullptr) { + if (this->read_byte_16(REG_CRATE, &raw)) { + this->rate_sensor_->publish_state(static_cast(raw) * CRATE_PERCENT_PER_HOUR_PER_LSB); + } else { + ok = false; + } + } + if (ok) { + this->status_clear_warning(); + } else { + this->status_set_warning(LOG_STR("Register read failed")); + } +} + +void MAX17048Component::dump_config() { + ESP_LOGCONFIG(TAG, "MAX17048:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_); + LOG_SENSOR(" ", "Rate", this->rate_sensor_); +} + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/max17048.h b/esphome/components/max17048/max17048.h new file mode 100644 index 0000000000..906d4a7c3f --- /dev/null +++ b/esphome/components/max17048/max17048.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::max17048 { + +class MAX17048Component final : public PollingComponent, public i2c::I2CDevice { + SUB_SENSOR(battery_voltage) + SUB_SENSOR(battery_level) + SUB_SENSOR(rate) + + public: + void setup() override; + void dump_config() override; + void update() override; +}; + +} // namespace esphome::max17048 diff --git a/esphome/components/max17048/sensor.py b/esphome/components/max17048/sensor.py new file mode 100644 index 0000000000..30cd0ec59f --- /dev/null +++ b/esphome/components/max17048/sensor.py @@ -0,0 +1,66 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_BATTERY_VOLTAGE, + CONF_ID, + CONF_RATE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_VOLTAGE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_PERCENT, + UNIT_VOLT, +) +from esphome.types import ConfigType + +DEPENDENCIES = ["i2c"] + +UNIT_PERCENT_PER_HOUR = "%/h" + +max17048_ns = cg.esphome_ns.namespace("max17048") +MAX17048Component = max17048_ns.class_( + "MAX17048Component", cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MAX17048Component), + cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=3, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=2, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT_PER_HOUR, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x36)) +) + + +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_BATTERY_VOLTAGE, var.set_battery_voltage_sensor) + await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor) + await sensors(CONF_RATE, var.set_rate_sensor) diff --git a/esphome/components/max31855/__init__.py b/esphome/components/max31855/__init__.py index e69de29bb2..b8a76cd500 100644 --- a/esphome/components/max31855/__init__.py +++ b/esphome/components/max31855/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max31855" diff --git a/esphome/components/max31855/max31855.cpp b/esphome/components/max31855/max31855.cpp index ac6b3cecb4..4bef41761c 100644 --- a/esphome/components/max31855/max31855.cpp +++ b/esphome/components/max31855/max31855.cpp @@ -5,7 +5,9 @@ namespace esphome::max31855 { -static const char *const TAG = "max31855"; +ESPHOME_LOG_TAG(TAG, "max31855"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; void MAX31855Sensor::update() { this->enable(); @@ -14,7 +16,7 @@ void MAX31855Sensor::update() { this->disable(); // Conversion time typ: 170ms, max: 220ms - this->set_timeout("value", 220, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, 220, [this]() { this->read_data_(); }); } void MAX31855Sensor::setup() { this->spi_setup(); } 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/max31856/__init__.py b/esphome/components/max31856/__init__.py index e69de29bb2..3fde339873 100644 --- a/esphome/components/max31856/__init__.py +++ b/esphome/components/max31856/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max31856" diff --git a/esphome/components/max31856/max31856.cpp b/esphome/components/max31856/max31856.cpp index b5bad8ef74..bdb47216da 100644 --- a/esphome/components/max31856/max31856.cpp +++ b/esphome/components/max31856/max31856.cpp @@ -5,7 +5,9 @@ namespace esphome::max31856 { -static const char *const TAG = "max31856"; +ESPHOME_LOG_TAG(TAG, "max31856"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; // Based on Adafruit's library: https://github.com/adafruit/Adafruit_MAX31856 @@ -42,7 +44,7 @@ void MAX31856Sensor::update() { this->one_shot_temperature_(); // Datasheet max conversion time for 1 shot is 155ms for 60Hz / 185ms for 50Hz - this->set_timeout("MAX31856Sensor::read_thermocouple_temperature_", filter_ == FILTER_60HZ ? 155 : 185, + this->set_timeout(READ_TIMEOUT_ID, filter_ == FILTER_60HZ ? 155 : 185, [this]() { this->read_thermocouple_temperature_(); }); } diff --git a/esphome/components/max31865/__init__.py b/esphome/components/max31865/__init__.py index e69de29bb2..42068dc04f 100644 --- a/esphome/components/max31865/__init__.py +++ b/esphome/components/max31865/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max31865" diff --git a/esphome/components/max31865/max31865.cpp b/esphome/components/max31865/max31865.cpp index e5a6fca8fb..94899b05ab 100644 --- a/esphome/components/max31865/max31865.cpp +++ b/esphome/components/max31865/max31865.cpp @@ -6,7 +6,9 @@ namespace esphome::max31865 { -static const char *const TAG = "max31865"; +ESPHOME_LOG_TAG(TAG, "max31865"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; void MAX31865Sensor::update() { // Check new faults since last measurement @@ -59,7 +61,7 @@ void MAX31865Sensor::update() { this->write_config_(0b11100000, 0b10100000); // Datasheet max conversion time is 55ms for 60Hz / 66ms for 50Hz - this->set_timeout("value", filter_ == FILTER_60HZ ? 55 : 66, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, filter_ == FILTER_60HZ ? 55 : 66, [this]() { this->read_data_(); }); } void MAX31865Sensor::setup() { diff --git a/esphome/components/max31888/__init__.py b/esphome/components/max31888/__init__.py new file mode 100644 index 0000000000..880005d6e7 --- /dev/null +++ b/esphome/components/max31888/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@JiriPrchal"] +DOMAIN = "max31888" diff --git a/esphome/components/max31888/max31888.cpp b/esphome/components/max31888/max31888.cpp new file mode 100644 index 0000000000..5ff8e6f1ef --- /dev/null +++ b/esphome/components/max31888/max31888.cpp @@ -0,0 +1,95 @@ +#include "max31888.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::max31888 { + +ESPHOME_LOG_TAG(TAG, "max31888"); + +static constexpr uint32_t CONVERSION_TIMEOUT_ID = 0; + +static constexpr uint8_t MAX31888_COMMAND_START_CONVERSION = 0x44; +static constexpr uint8_t MAX31888_COMMAND_READ = 0x33; +static constexpr uint8_t MAX31888_COMMAND_SOFT_RESET = 0x82; +static constexpr uint8_t MAX31888_REGISTER_FIFO_DATA = 0x08; +static constexpr uint8_t MAX31888_FIFO_READ_LENGTH = 0x01; // number of bytes to read, minus one +static constexpr uint16_t MAX31888_CONVERSION_TIME_MS = 20; +static constexpr float MAX31888_DEGREES_PER_LSB = 0.005f; + +// Sends a command and checks the checksum the device appends to it (Maxim CRC-16 over the command byte, +// sent inverted and low byte first) +bool MAX31888Sensor::send_checked_command_(uint8_t command) { + if (!this->send_command_(command)) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + const uint8_t low = this->bus_->read8(); + const uint8_t high = this->bus_->read8(); + if (crc16(&command, 1, 0, 0xa001, false, true) != encode_uint16(high, low)) { + this->status_set_warning(LOG_STR("command checksum invalid")); + return false; + } + return true; +} + +void MAX31888Sensor::setup() { + if (!this->check_address_or_index_()) + return; + if (!this->send_checked_command_(MAX31888_COMMAND_SOFT_RESET)) { + this->mark_failed(); + } +} + +void MAX31888Sensor::dump_config() { + ESP_LOGCONFIG(TAG, "MAX31888 Sensor:"); + if (this->address_ == 0) { + ESP_LOGW(TAG, " Unable to select an address"); + return; + } + LOG_ONE_WIRE_DEVICE(this); + LOG_UPDATE_INTERVAL(this); +} + +void MAX31888Sensor::update() { + if (this->address_ == 0) + return; + + if (!this->send_checked_command_(MAX31888_COMMAND_START_CONVERSION)) { + this->publish_state(NAN); + return; + } + + this->set_timeout(CONVERSION_TIMEOUT_ID, MAX31888_CONVERSION_TIME_MS, [this] { + int16_t raw; + if (!this->read_temperature_(raw)) { + this->publish_state(NAN); + return; + } + const float temperature = raw * MAX31888_DEGREES_PER_LSB; + ESP_LOGD(TAG, "'%s': Got Temperature=%.3f°C", this->get_name().c_str(), temperature); + this->status_clear_warning(); + this->publish_state(temperature); + }); +} + +bool MAX31888Sensor::read_temperature_(int16_t &raw) { + // The checksum covers the command and its two arguments as well as the data + uint8_t frame[7] = {MAX31888_COMMAND_READ, MAX31888_REGISTER_FIFO_DATA, MAX31888_FIFO_READ_LENGTH}; + if (!this->send_command_(frame[0])) { + this->status_set_warning(LOG_STR("bus reset failed")); + return false; + } + this->bus_->write8(frame[1]); + this->bus_->write8(frame[2]); + for (size_t i = 3; i < sizeof(frame); i++) { + frame[i] = this->bus_->read8(); + } + if (crc16(frame, 5, 0, 0xa001, false, true) != encode_uint16(frame[6], frame[5])) { + this->status_set_warning(LOG_STR("checksum invalid")); + return false; + } + raw = static_cast(encode_uint16(frame[3], frame[4])); + return true; +} + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/max31888.h b/esphome/components/max31888/max31888.h new file mode 100644 index 0000000000..666f301cb0 --- /dev/null +++ b/esphome/components/max31888/max31888.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/one_wire/one_wire.h" + +namespace esphome::max31888 { + +class MAX31888Sensor final : public PollingComponent, public sensor::Sensor, public one_wire::OneWireDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + bool send_checked_command_(uint8_t command); + /// Reads the latest conversion from the FIFO; false when the bus or the checksum fails + bool read_temperature_(int16_t &raw); +}; + +} // namespace esphome::max31888 diff --git a/esphome/components/max31888/sensor.py b/esphome/components/max31888/sensor.py new file mode 100644 index 0000000000..2809e7531a --- /dev/null +++ b/esphome/components/max31888/sensor.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import one_wire, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) +from esphome.types import ConfigType + +max31888_ns = cg.esphome_ns.namespace("max31888") + +MAX31888Sensor = max31888_ns.class_( + "MAX31888Sensor", + cg.PollingComponent, + sensor.Sensor, + one_wire.OneWireDevice, +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + MAX31888Sensor, + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=2, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(one_wire.one_wire_device_schema()) + .extend(cv.polling_component_schema("60s")) +) + + +async def to_code(config: ConfigType) -> None: + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await one_wire.register_one_wire_device(var, config) diff --git a/esphome/components/max44009/__init__.py b/esphome/components/max44009/__init__.py index e69de29bb2..a7d755f6a6 100644 --- a/esphome/components/max44009/__init__.py +++ b/esphome/components/max44009/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max44009" diff --git a/esphome/components/max44009/max44009.cpp b/esphome/components/max44009/max44009.cpp index 731f584056..b616b9032e 100644 --- a/esphome/components/max44009/max44009.cpp +++ b/esphome/components/max44009/max44009.cpp @@ -4,7 +4,7 @@ namespace esphome::max44009 { -static const char *const TAG = "max44009.sensor"; +ESPHOME_LOG_TAG(TAG, "max44009.sensor"); // REGISTERS static constexpr uint8_t MAX44009_REGISTER_CONFIGURATION = 0x02; diff --git a/esphome/components/max6675/__init__.py b/esphome/components/max6675/__init__.py index e69de29bb2..d00df9770d 100644 --- a/esphome/components/max6675/__init__.py +++ b/esphome/components/max6675/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max6675" diff --git a/esphome/components/max6675/max6675.cpp b/esphome/components/max6675/max6675.cpp index 8734405508..2fc8dbb73d 100644 --- a/esphome/components/max6675/max6675.cpp +++ b/esphome/components/max6675/max6675.cpp @@ -3,7 +3,9 @@ namespace esphome::max6675 { -static const char *const TAG = "max6675"; +ESPHOME_LOG_TAG(TAG, "max6675"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; void MAX6675Sensor::update() { this->enable(); @@ -12,7 +14,7 @@ void MAX6675Sensor::update() { this->disable(); // Conversion time typ: 170ms, max: 220ms - this->set_timeout("value", 250, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, 250, [this]() { this->read_data_(); }); } void MAX6675Sensor::setup() { this->spi_setup(); } diff --git a/esphome/components/max6956/__init__.py b/esphome/components/max6956/__init__.py index 5e45d71899..77e82fdec7 100644 --- a/esphome/components/max6956/__init__.py +++ b/esphome/components/max6956/__init__.py @@ -11,11 +11,11 @@ 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"] +DOMAIN = "max6956" DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -29,10 +29,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 +80,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 +99,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 +110,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 +125,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/max6956/max6956.cpp b/esphome/components/max6956/max6956.cpp index ccb14496aa..7060a8c76b 100644 --- a/esphome/components/max6956/max6956.cpp +++ b/esphome/components/max6956/max6956.cpp @@ -3,7 +3,7 @@ namespace esphome::max6956 { -static const char *const TAG = "max6956"; +ESPHOME_LOG_TAG(TAG, "max6956"); /// Masks for MAX6956 Configuration register const uint32_t MASK_TRANSITION_DETECTION = 0x80; diff --git a/esphome/components/max6956/output/max6956_led_output.cpp b/esphome/components/max6956/output/max6956_led_output.cpp index c53a429d20..e3c9c1e827 100644 --- a/esphome/components/max6956/output/max6956_led_output.cpp +++ b/esphome/components/max6956/output/max6956_led_output.cpp @@ -4,7 +4,7 @@ namespace esphome::max6956 { -static const char *const TAG = "max6956_led_channel"; +ESPHOME_LOG_TAG(TAG, "max6956_led_channel"); void MAX6956LedChannel::write_state(float state) { this->parent_->set_pin_brightness(this->pin_, state); } diff --git a/esphome/components/max7219/__init__.py b/esphome/components/max7219/__init__.py index e69de29bb2..2c1a783395 100644 --- a/esphome/components/max7219/__init__.py +++ b/esphome/components/max7219/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max7219" diff --git a/esphome/components/max7219/max7219.cpp b/esphome/components/max7219/max7219.cpp index bec62ea005..d755dd1a22 100644 --- a/esphome/components/max7219/max7219.cpp +++ b/esphome/components/max7219/max7219.cpp @@ -5,7 +5,7 @@ namespace esphome::max7219 { -static const char *const TAG = "max7219"; +ESPHOME_LOG_TAG(TAG, "max7219"); static const uint8_t MAX7219_REGISTER_NOOP = 0x00; static const uint8_t MAX7219_REGISTER_DECODE_MODE = 0x09; diff --git a/esphome/components/max7219digit/__init__.py b/esphome/components/max7219digit/__init__.py index e69de29bb2..33d7ad3f80 100644 --- a/esphome/components/max7219digit/__init__.py +++ b/esphome/components/max7219digit/__init__.py @@ -0,0 +1 @@ +DOMAIN = "max7219digit" 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/max7219digit/max7219digit.cpp b/esphome/components/max7219digit/max7219digit.cpp index 26c65aa5d3..178a6fa23a 100644 --- a/esphome/components/max7219digit/max7219digit.cpp +++ b/esphome/components/max7219digit/max7219digit.cpp @@ -10,7 +10,7 @@ namespace esphome::max7219digit { -static const char *const TAG = "max7219DIGIT"; +ESPHOME_LOG_TAG(TAG, "max7219DIGIT"); static const uint8_t MAX7219_REGISTER_NOOP = 0x00; static const uint8_t MAX7219_REGISTER_DECODE_MODE = 0x09; diff --git a/esphome/components/max9611/__init__.py b/esphome/components/max9611/__init__.py index 9b0ea14b57..a3fc6ca774 100644 --- a/esphome/components/max9611/__init__.py +++ b/esphome/components/max9611/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@mckaymatthew"] +DOMAIN = "max9611" diff --git a/esphome/components/max9611/max9611.cpp b/esphome/components/max9611/max9611.cpp index 68ef7c3135..420eb7da7c 100644 --- a/esphome/components/max9611/max9611.cpp +++ b/esphome/components/max9611/max9611.cpp @@ -24,7 +24,7 @@ float gain_to_lsb(MAX9611Multiplexer gain) { } return lsb; } -static const char *const TAG = "max9611"; +ESPHOME_LOG_TAG(TAG, "max9611"); static const uint8_t SETUP_DELAY = 4; // Wait 2 integration periods. static const float VOUT_LSB = 14.0 / 1000.0; // 14mV/LSB static const float TEMP_LSB = 0.48; // 0.48C/LSB 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/mcp23008/__init__.py b/esphome/components/mcp23008/__init__.py index 3d1480a6f7..9f1e9c9f96 100644 --- a/esphome/components/mcp23008/__init__.py +++ b/esphome/components/mcp23008/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["mcp23x08_base"] CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp23008" MULTI_CONF = True mcp23008_ns = cg.esphome_ns.namespace("mcp23008") diff --git a/esphome/components/mcp23008/mcp23008.cpp b/esphome/components/mcp23008/mcp23008.cpp index 6be5f4c951..3d2eb618d6 100644 --- a/esphome/components/mcp23008/mcp23008.cpp +++ b/esphome/components/mcp23008/mcp23008.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23008 { -static const char *const TAG = "mcp23008"; +ESPHOME_LOG_TAG(TAG, "mcp23008"); static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 5f4b7276d8..7dddf4b042 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp23016" MULTI_CONF = True mcp23016_ns = cg.esphome_ns.namespace("mcp23016") @@ -60,7 +61,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/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index 126ece3e7b..13d930fd47 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp23016 { -static const char *const TAG = "mcp23016"; +ESPHOME_LOG_TAG(TAG, "mcp23016"); void MCP23016::setup() { uint16_t iocon; diff --git a/esphome/components/mcp23017/__init__.py b/esphome/components/mcp23017/__init__.py index 474d75f6ff..6733f19456 100644 --- a/esphome/components/mcp23017/__init__.py +++ b/esphome/components/mcp23017/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["mcp23x17_base"] CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp23017" MULTI_CONF = True mcp23017_ns = cg.esphome_ns.namespace("mcp23017") diff --git a/esphome/components/mcp23017/mcp23017.cpp b/esphome/components/mcp23017/mcp23017.cpp index 173d117457..9cad032447 100644 --- a/esphome/components/mcp23017/mcp23017.cpp +++ b/esphome/components/mcp23017/mcp23017.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23017 { -static const char *const TAG = "mcp23017"; +ESPHOME_LOG_TAG(TAG, "mcp23017"); static constexpr uint8_t IOCON_MIRROR = 0x40; // Mirror INTA/INTB pins static constexpr uint8_t IOCON_ODR = 0x04; // Open-drain output for INT pin diff --git a/esphome/components/mcp23s08/__init__.py b/esphome/components/mcp23s08/__init__.py index ffc51b8146..95a396865b 100644 --- a/esphome/components/mcp23s08/__init__.py +++ b/esphome/components/mcp23s08/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["mcp23x08_base"] CODEOWNERS = ["@SenexCrenshaw", "@jesserockz"] DEPENDENCIES = ["spi"] +DOMAIN = "mcp23s08" MULTI_CONF = True CONF_DEVICEADDRESS = "deviceaddress" diff --git a/esphome/components/mcp23s08/mcp23s08.cpp b/esphome/components/mcp23s08/mcp23s08.cpp index e7f582f787..341951f119 100644 --- a/esphome/components/mcp23s08/mcp23s08.cpp +++ b/esphome/components/mcp23s08/mcp23s08.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23s08 { -static const char *const TAG = "mcp23s08"; +ESPHOME_LOG_TAG(TAG, "mcp23s08"); // IOCON register bits static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode diff --git a/esphome/components/mcp23s17/__init__.py b/esphome/components/mcp23s17/__init__.py index d693a64ce8..9745b74bc0 100644 --- a/esphome/components/mcp23s17/__init__.py +++ b/esphome/components/mcp23s17/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["mcp23x17_base"] CODEOWNERS = ["@SenexCrenshaw", "@jesserockz"] DEPENDENCIES = ["spi"] +DOMAIN = "mcp23s17" MULTI_CONF = True CONF_DEVICEADDRESS = "deviceaddress" diff --git a/esphome/components/mcp23s17/mcp23s17.cpp b/esphome/components/mcp23s17/mcp23s17.cpp index 652931e9a9..534327f401 100644 --- a/esphome/components/mcp23s17/mcp23s17.cpp +++ b/esphome/components/mcp23s17/mcp23s17.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23s17 { -static const char *const TAG = "mcp23s17"; +ESPHOME_LOG_TAG(TAG, "mcp23s17"); // IOCON register bits static constexpr uint8_t IOCON_SEQOP = 0x20; // Sequential operation mode diff --git a/esphome/components/mcp23x08_base/__init__.py b/esphome/components/mcp23x08_base/__init__.py index a3c12165f0..d283d57bd0 100644 --- a/esphome/components/mcp23x08_base/__init__.py +++ b/esphome/components/mcp23x08_base/__init__.py @@ -3,6 +3,7 @@ from esphome.components import mcp23xxx_base AUTO_LOAD = ["mcp23xxx_base"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "mcp23x08_base" NUM_PINS = 8 diff --git a/esphome/components/mcp23x08_base/mcp23x08_base.cpp b/esphome/components/mcp23x08_base/mcp23x08_base.cpp index 98fd2f6249..0cda5dac4a 100644 --- a/esphome/components/mcp23x08_base/mcp23x08_base.cpp +++ b/esphome/components/mcp23x08_base/mcp23x08_base.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp23x08_base { -static const char *const TAG = "mcp23x08_base"; +ESPHOME_LOG_TAG(TAG, "mcp23x08_base"); bool MCP23X08Base::digital_read_hw(uint8_t pin) { if (!this->read_reg(mcp23x08_base::MCP23X08_GPIO, &this->input_mask_)) { diff --git a/esphome/components/mcp23x17_base/__init__.py b/esphome/components/mcp23x17_base/__init__.py index 1b93d16ff3..6160987cef 100644 --- a/esphome/components/mcp23x17_base/__init__.py +++ b/esphome/components/mcp23x17_base/__init__.py @@ -3,6 +3,7 @@ from esphome.components import mcp23xxx_base AUTO_LOAD = ["mcp23xxx_base"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "mcp23x17_base" NUM_PINS = 16 diff --git a/esphome/components/mcp23x17_base/mcp23x17_base.cpp b/esphome/components/mcp23x17_base/mcp23x17_base.cpp index 870c6c165f..7ae17b1c13 100644 --- a/esphome/components/mcp23x17_base/mcp23x17_base.cpp +++ b/esphome/components/mcp23x17_base/mcp23x17_base.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp23x17_base { -static const char *const TAG = "mcp23x17_base"; +ESPHOME_LOG_TAG(TAG, "mcp23x17_base"); bool MCP23X17Base::digital_read_hw(uint8_t pin) { uint8_t data; diff --git a/esphome/components/mcp23xxx_base/__init__.py b/esphome/components/mcp23xxx_base/__init__.py index 755d86e4ea..f7449b0ab9 100644 --- a/esphome/components/mcp23xxx_base/__init__.py +++ b/esphome/components/mcp23xxx_base/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "mcp23xxx_base" mcp23xxx_base_ns = cg.esphome_ns.namespace("mcp23xxx_base") MCP23XXXBase = mcp23xxx_base_ns.class_("MCP23XXXBase", cg.Component) @@ -74,7 +75,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/mcp23xxx_base/mcp23xxx_base.cpp b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp index b8032fcec8..8ad2f7521e 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.cpp @@ -4,6 +4,8 @@ namespace esphome::mcp23xxx_base { +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg) { arg->enable_loop_soon_any_context(); } + template void MCP23XXXGPIOPin::setup() { this->pin_mode(flags_); // When interrupt_pin is configured, pin_mode() already auto-enables CHANGE diff --git a/esphome/components/mcp23xxx_base/mcp23xxx_base.h b/esphome/components/mcp23xxx_base/mcp23xxx_base.h index 1c45b0f4af..7414677175 100644 --- a/esphome/components/mcp23xxx_base/mcp23xxx_base.h +++ b/esphome/components/mcp23xxx_base/mcp23xxx_base.h @@ -8,6 +8,10 @@ namespace esphome::mcp23xxx_base { enum MCP23XXXInterruptMode : uint8_t { MCP23XXX_NO_INTERRUPT = 0, MCP23XXX_CHANGE, MCP23XXX_RISING, MCP23XXX_FALLING }; +// Not a template member: GCC 10 (ESP8266) ignores section attributes on template instantiations, leaving the +// ISR in flash. +void IRAM_ATTR mcp23xxx_gpio_intr(Component *arg); + template class MCP23XXXBase : public Component, public gpio_expander::CachedGpioExpander { public: virtual void pin_mode(uint8_t pin, gpio::Flags flags); @@ -35,7 +39,8 @@ template class MCP23XXXBase : public Component, public gpio_expander: void setup_interrupt_pin_() { if (this->interrupt_pin_ != nullptr) { this->interrupt_pin_->setup(); - this->interrupt_pin_->attach_interrupt(&MCP23XXXBase::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->interrupt_pin_->attach_interrupt(&mcp23xxx_gpio_intr, static_cast(this), + gpio::INTERRUPT_FALLING_EDGE); this->set_invalidate_on_read_(false); } // Disable loop until an input pin is configured via pin_mode() @@ -43,7 +48,6 @@ template class MCP23XXXBase : public Component, public gpio_expander: // For polling mode, loop is re-enabled when pin_mode() registers an input pin this->disable_loop(); } - static void IRAM_ATTR gpio_intr(MCP23XXXBase *arg) { arg->enable_loop_soon_any_context(); } // read a given register virtual bool read_reg(uint8_t reg, uint8_t *value) = 0; diff --git a/esphome/components/mcp2515/__init__.py b/esphome/components/mcp2515/__init__.py index e69de29bb2..5b0e72b758 100644 --- a/esphome/components/mcp2515/__init__.py +++ b/esphome/components/mcp2515/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp2515" diff --git a/esphome/components/mcp2515/canbus.py b/esphome/components/mcp2515/canbus.py index 8bb8918f96..eab9be0f70 100644 --- a/esphome/components/mcp2515/canbus.py +++ b/esphome/components/mcp2515/canbus.py @@ -40,7 +40,7 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( async def to_code(config: ConfigType) -> None: rhs = mcp2515.new() var = cg.Pvariable(config[CONF_ID], rhs) - await canbus.register_canbus(var, config) + await canbus.register_canbus(var, config, reports_status=True) if CONF_CLOCK in config: canclock = CAN_CLOCK[config[CONF_CLOCK]] cg.add(var.set_mcp_clock(canclock)) diff --git a/esphome/components/mcp2515/mcp2515.cpp b/esphome/components/mcp2515/mcp2515.cpp index f8c5e9f068..599a864e54 100644 --- a/esphome/components/mcp2515/mcp2515.cpp +++ b/esphome/components/mcp2515/mcp2515.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp2515 { -static const char *const TAG = "mcp2515"; +ESPHOME_LOG_TAG(TAG, "mcp2515"); const struct MCP2515::TxBnRegs MCP2515::TXB[N_TXBUFFERS] = {{MCP_TXB0CTRL, MCP_TXB0SIDH, MCP_TXB0DATA}, {MCP_TXB1CTRL, MCP_TXB1SIDH, MCP_TXB1DATA}, @@ -43,6 +43,48 @@ bool MCP2515::setup_internal() { return true; } +canbus::CanEventFlags MCP2515::get_events() { + uint8_t events = 0; + uint8_t error_flags = this->get_error_flags_(); + + // Error-passive is one state for RX and TX together, so only a change of the combined flag is an event + bool was_passive = this->last_error_flags_ & (EFLG_RXEP | EFLG_TXEP); + bool is_passive = error_flags & (EFLG_RXEP | EFLG_TXEP); + if (was_passive != is_passive) { + events |= is_passive ? canbus::CAN_EVENT_PASSIVE : canbus::CAN_EVENT_ACTIVE; + } + if ((this->last_error_flags_ ^ error_flags) & EFLG_TXBO) { + events |= canbus::CAN_EVENT_BUS_OFF; + } + + // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set + // once both buffers are full. However, the RX0OVR flag is actually set instead. + // We can just check for both though because it doesn't break anything. + if (error_flags & (EFLG_RX0OVR | EFLG_RX1OVR)) { + events |= canbus::CAN_EVENT_RX_QUEUE_FULL; + this->clear_rx_n_ovr_flags_(); + } + + this->last_error_flags_ = error_flags; + return static_cast(events); +} + +canbus::CanStatus MCP2515::get_status() { + canbus::CanStatus status = {}; + + uint8_t error_flags = this->get_error_flags_(); + status.bus_off = error_flags & EFLG_TXBO; + + // Transmit Error Counter is followed by Receive Error Counter so we're reading both at once + uint8_t err_counters[2]; + this->read_registers_(MCP_TEC, err_counters, 2); + + status.rx_error_counter = err_counters[1]; + status.tx_error_counter = err_counters[0]; + + return status; +} + canbus::Error MCP2515::reset_() { this->enable(); this->transfer_byte(INSTRUCTION_RESET); @@ -331,17 +373,6 @@ canbus::Error MCP2515::read_message(struct canbus::CanFrame *frame) { rc = canbus::ERROR_NOMSG; } -#ifdef ESPHOME_LOG_HAS_DEBUG - uint8_t err = get_error_flags_(); - // The receive flowchart in the datasheet says that if rollover is set (BUKT), RX1OVR flag will be set - // once both buffers are full. However, the RX0OVR flag is actually set instead. - // We can just check for both though because it doesn't break anything. - if (err & (EFLG_RX0OVR | EFLG_RX1OVR)) { - ESP_LOGD(TAG, "receive buffer overrun"); - clear_rx_n_ovr_flags_(); - } -#endif - return rc; } diff --git a/esphome/components/mcp2515/mcp2515.h b/esphome/components/mcp2515/mcp2515.h index 960e150800..8d2fe4d51b 100644 --- a/esphome/components/mcp2515/mcp2515.h +++ b/esphome/components/mcp2515/mcp2515.h @@ -58,6 +58,7 @@ class MCP2515 final : public canbus::Canbus, MCP2515(){}; void set_mcp_clock(CanClock clock) { this->mcp_clock_ = clock; }; void set_mcp_mode(const CanctrlReqopMode mode) { this->mcp_mode_ = mode; } + canbus::CanStatus get_status() override; static const struct TxBnRegs { REGISTER CTRL; REGISTER SIDH; @@ -74,7 +75,11 @@ class MCP2515 final : public canbus::Canbus, protected: CanClock mcp_clock_{MCP_8MHZ}; CanctrlReqopMode mcp_mode_ = CANCTRL_REQOP_NORMAL; + + uint8_t last_error_flags_{0}; + bool setup_internal() override; + canbus::CanEventFlags get_events() override; canbus::Error set_mode_(CanctrlReqopMode mode); uint8_t read_register_(REGISTER reg); diff --git a/esphome/components/mcp3008/__init__.py b/esphome/components/mcp3008/__init__.py index 6d1bd5a970..f29050f9fe 100644 --- a/esphome/components/mcp3008/__init__.py +++ b/esphome/components/mcp3008/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["spi"] AUTO_LOAD = ["sensor"] +DOMAIN = "mcp3008" MULTI_CONF = True CONF_MCP3008 = "mcp3008" diff --git a/esphome/components/mcp3008/mcp3008.cpp b/esphome/components/mcp3008/mcp3008.cpp index e65e249f52..2ee6ce5b9b 100644 --- a/esphome/components/mcp3008/mcp3008.cpp +++ b/esphome/components/mcp3008/mcp3008.cpp @@ -5,7 +5,7 @@ namespace esphome::mcp3008 { -static const char *const TAG = "mcp3008"; +ESPHOME_LOG_TAG(TAG, "mcp3008"); float MCP3008::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/mcp3008/sensor/mcp3008_sensor.cpp b/esphome/components/mcp3008/sensor/mcp3008_sensor.cpp index 2647ed199e..3a119f7d2a 100644 --- a/esphome/components/mcp3008/sensor/mcp3008_sensor.cpp +++ b/esphome/components/mcp3008/sensor/mcp3008_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp3008 { -static const char *const TAG = "mcp3008.sensor"; +ESPHOME_LOG_TAG(TAG, "mcp3008.sensor"); void MCP3008Sensor::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/mcp3204/__init__.py b/esphome/components/mcp3204/__init__.py index 5757bdbfa9..7829a07d80 100644 --- a/esphome/components/mcp3204/__init__.py +++ b/esphome/components/mcp3204/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID, CONF_REFERENCE_VOLTAGE from esphome.types import ConfigType DEPENDENCIES = ["spi"] +DOMAIN = "mcp3204" MULTI_CONF = True CODEOWNERS = ["@rsumner"] diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 33abbe847a..dddc043f0e 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp3204 { -static const char *const TAG = "mcp3204"; +ESPHOME_LOG_TAG(TAG, "mcp3204"); float MCP3204::get_setup_priority() const { return setup_priority::HARDWARE; } @@ -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/mcp3204/sensor/mcp3204_sensor.cpp b/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp index 153c37d160..188d7a1dfc 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp3204 { -static const char *const TAG = "mcp3204.sensor"; +ESPHOME_LOG_TAG(TAG, "mcp3204.sensor"); void MCP3204Sensor::dump_config() { LOG_SENSOR("", "MCP3204 Sensor", this); diff --git a/esphome/components/mcp3221/__init__.py b/esphome/components/mcp3221/__init__.py index 677bb78c35..8e5a7f134e 100644 --- a/esphome/components/mcp3221/__init__.py +++ b/esphome/components/mcp3221/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@philippderdiedas"] +DOMAIN = "mcp3221" diff --git a/esphome/components/mcp3221/mcp3221_sensor.cpp b/esphome/components/mcp3221/mcp3221_sensor.cpp index 1b794ba966..d38883c342 100644 --- a/esphome/components/mcp3221/mcp3221_sensor.cpp +++ b/esphome/components/mcp3221/mcp3221_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp3221 { -static const char *const TAG = "mcp3221"; +ESPHOME_LOG_TAG(TAG, "mcp3221"); float MCP3221Sensor::sample() { uint8_t data[2]; diff --git a/esphome/components/mcp4461/__init__.py b/esphome/components/mcp4461/__init__.py index 60cece67d7..d5465098f2 100644 --- a/esphome/components/mcp4461/__init__.py +++ b/esphome/components/mcp4461/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@p1ngb4ck"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp4461" MULTI_CONF = True CONF_DISABLE_WIPER_0 = "disable_wiper_0" CONF_DISABLE_WIPER_1 = "disable_wiper_1" diff --git a/esphome/components/mcp4461/mcp4461.cpp b/esphome/components/mcp4461/mcp4461.cpp index cc53f9de7f..b6e8c24702 100644 --- a/esphome/components/mcp4461/mcp4461.cpp +++ b/esphome/components/mcp4461/mcp4461.cpp @@ -5,7 +5,7 @@ namespace esphome::mcp4461 { -static const char *const TAG = "mcp4461"; +ESPHOME_LOG_TAG(TAG, "mcp4461"); constexpr uint8_t EEPROM_WRITE_TIMEOUT_MS = 10; void Mcp4461Component::setup() { 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 d38eed4d09..6c1ab99e29 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.cpp +++ b/esphome/components/mcp4461/output/mcp4461_output.cpp @@ -6,7 +6,7 @@ namespace esphome::mcp4461 { -static const char *const TAG = "mcp4461.output"; +ESPHOME_LOG_TAG(TAG, "mcp4461.output"); // public set_level function void Mcp4461Wiper::set_level(float state) { diff --git a/esphome/components/mcp4461/output/mcp4461_output.h b/esphome/components/mcp4461/output/mcp4461_output.h index 1052369a74..f0af6037e7 100644 --- a/esphome/components/mcp4461/output/mcp4461_output.h +++ b/esphome/components/mcp4461/output/mcp4461_output.h @@ -33,6 +33,14 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parentedenable_terminal(terminal); + } else { + this->disable_terminal(terminal); + } + } /// @brief Immediately persist the current wiper level to the chip's nonvolatile register /// (independent of the deferred nonvolatile mirroring / its stability delay) void store_nonvolatile(); diff --git a/esphome/components/mcp4725/__init__.py b/esphome/components/mcp4725/__init__.py index e69de29bb2..d63ffaf7c3 100644 --- a/esphome/components/mcp4725/__init__.py +++ b/esphome/components/mcp4725/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp4725" diff --git a/esphome/components/mcp4725/mcp4725.cpp b/esphome/components/mcp4725/mcp4725.cpp index 8e94623de3..faa377b8de 100644 --- a/esphome/components/mcp4725/mcp4725.cpp +++ b/esphome/components/mcp4725/mcp4725.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp4725 { -static const char *const TAG = "mcp4725"; +ESPHOME_LOG_TAG(TAG, "mcp4725"); void MCP4725::setup() { auto err = this->write(nullptr, 0); diff --git a/esphome/components/mcp4728/__init__.py b/esphome/components/mcp4728/__init__.py index f48bdde681..7156b4aa7f 100644 --- a/esphome/components/mcp4728/__init__.py +++ b/esphome/components/mcp4728/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@berfenger"] DEPENDENCIES = ["i2c"] +DOMAIN = "mcp4728" MULTI_CONF = True CONF_STORE_IN_EEPROM = "store_in_eeprom" diff --git a/esphome/components/mcp4728/mcp4728.cpp b/esphome/components/mcp4728/mcp4728.cpp index 1b15ca0510..7259a0a89e 100644 --- a/esphome/components/mcp4728/mcp4728.cpp +++ b/esphome/components/mcp4728/mcp4728.cpp @@ -5,7 +5,7 @@ namespace esphome::mcp4728 { -static const char *const TAG = "mcp4728"; +ESPHOME_LOG_TAG(TAG, "mcp4728"); void MCP4728Component::setup() { auto err = this->write(nullptr, 0); diff --git a/esphome/components/mcp47a1/__init__.py b/esphome/components/mcp47a1/__init__.py index e69de29bb2..274ca6fa01 100644 --- a/esphome/components/mcp47a1/__init__.py +++ b/esphome/components/mcp47a1/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp47a1" diff --git a/esphome/components/mcp47a1/mcp47a1.cpp b/esphome/components/mcp47a1/mcp47a1.cpp index e2b0fa5575..8bbde59006 100644 --- a/esphome/components/mcp47a1/mcp47a1.cpp +++ b/esphome/components/mcp47a1/mcp47a1.cpp @@ -4,7 +4,7 @@ namespace esphome::mcp47a1 { -static const char *const TAG = "mcp47a1"; +ESPHOME_LOG_TAG(TAG, "mcp47a1"); void MCP47A1::dump_config() { ESP_LOGCONFIG(TAG, "MCP47A1 Output:"); diff --git a/esphome/components/mcp9600/__init__.py b/esphome/components/mcp9600/__init__.py index e69de29bb2..3d85a4d937 100644 --- a/esphome/components/mcp9600/__init__.py +++ b/esphome/components/mcp9600/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp9600" diff --git a/esphome/components/mcp9600/mcp9600.cpp b/esphome/components/mcp9600/mcp9600.cpp index 0e9b472be2..70e7cb176d 100644 --- a/esphome/components/mcp9600/mcp9600.cpp +++ b/esphome/components/mcp9600/mcp9600.cpp @@ -3,7 +3,7 @@ namespace esphome::mcp9600 { -static const char *const TAG = "mcp9600"; +ESPHOME_LOG_TAG(TAG, "mcp9600"); static const uint8_t MCP9600_REGISTER_HOT_JUNCTION = 0x00; // static const uint8_t MCP9600_REGISTER_JUNCTION_DELTA = 0x01; // Unused, but kept for future reference 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/mcp9808/__init__.py b/esphome/components/mcp9808/__init__.py index e69de29bb2..7a167ebf06 100644 --- a/esphome/components/mcp9808/__init__.py +++ b/esphome/components/mcp9808/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mcp9808" diff --git a/esphome/components/mcp9808/mcp9808.cpp b/esphome/components/mcp9808/mcp9808.cpp index 10e26ed709..47df37f456 100644 --- a/esphome/components/mcp9808/mcp9808.cpp +++ b/esphome/components/mcp9808/mcp9808.cpp @@ -14,7 +14,7 @@ static const uint8_t MCP9808_AMBIENT_CLEAR_FLAGS = 0x1F; static const uint8_t MCP9808_AMBIENT_CLEAR_SIGN = 0x0F; static const uint8_t MCP9808_AMBIENT_TEMP_NEGATIVE = 0x10; -static const char *const TAG = "mcp9808"; +ESPHOME_LOG_TAG(TAG, "mcp9808"); void MCP9808Sensor::setup() { uint16_t manu = 0; diff --git a/esphome/components/mcp9808/sensor.py b/esphome/components/mcp9808/sensor.py index 1daaa9c131..2238e3d853 100644 --- a/esphome/components/mcp9808/sensor.py +++ b/esphome/components/mcp9808/sensor.py @@ -8,7 +8,6 @@ from esphome.const import ( ) from esphome.types import ConfigType -CODEOWNERS = ["@k7hpn"] DEPENDENCIES = ["i2c"] mcp9808_ns = cg.esphome_ns.namespace("mcp9808") diff --git a/esphome/components/md5/__init__.py b/esphome/components/md5/__init__.py index 6a928d1682..0928766429 100644 --- a/esphome/components/md5/__init__.py +++ b/esphome/components/md5/__init__.py @@ -4,6 +4,7 @@ from esphome.helpers import IS_MACOS from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "md5" async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 0fb24fdf1d..e49a3897fe 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -5,7 +5,6 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, CONF_ID, - CONF_MDNS, CONF_OPENTHREAD, CONF_PORT, CONF_PROTOCOL, @@ -22,6 +21,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +DOMAIN = "mdns" # Components that create mDNS services at runtime # IMPORTANT: If you add a new component here, you must also update the corresponding @@ -194,7 +194,7 @@ def request_service_enable_disable() -> bool: Public API for external components. Do not remove. """ - mdns_config = CORE.config.get(CONF_MDNS) + mdns_config = CORE.config.get(DOMAIN) if ( mdns_config is None or mdns_config[CONF_DISABLED] @@ -235,7 +235,7 @@ async def to_code(config: ConfigType) -> None: ethernet.request_ethernet_ip_state_listener() if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.12.0") + add_idf_component(name="espressif/mdns", ref="1.14.0") # ESPHome only advertises; the browse APIs are unused add_idf_sdkconfig_option("CONFIG_MDNS_ENABLE_BROWSE", False) # The mdns console CLI is never used by ESPHome diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4f236153c2..4397c101fc 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -23,7 +23,7 @@ namespace esphome::mdns { -static const char *const TAG = "mdns"; +ESPHOME_LOG_TAG(TAG, "mdns"); #ifndef USE_WEBSERVER_PORT #define USE_WEBSERVER_PORT 80 // NOLINT diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 48df61326e..1df5b9217e 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -10,7 +10,7 @@ namespace esphome::mdns { -static const char *const TAG = "mdns"; +ESPHOME_LOG_TAG(TAG, "mdns"); #ifndef USE_OPENTHREAD static esp_err_t add_service(const MDNSService &service) { diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index d99e7877bf..d012773d8a 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -29,6 +29,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] +DOMAIN = "media_player" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/media_player/media_player.cpp b/esphome/components/media_player/media_player.cpp index 6c3eef912e..423832dd2f 100644 --- a/esphome/components/media_player/media_player.cpp +++ b/esphome/components/media_player/media_player.cpp @@ -6,7 +6,7 @@ namespace esphome::media_player { -static const char *const TAG = "media_player"; +ESPHOME_LOG_TAG(TAG, "media_player"); const char *media_player_state_to_string(MediaPlayerState state) { switch (state) { diff --git a/esphome/components/media_source/__init__.py b/esphome/components/media_source/__init__.py index c9dab7e4d2..348ea01428 100644 --- a/esphome/components/media_source/__init__.py +++ b/esphome/components/media_source/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@kahrendt"] +DOMAIN = "media_source" AUTO_LOAD = ["audio"] diff --git a/esphome/components/mhz19/__init__.py b/esphome/components/mhz19/__init__.py index e69de29bb2..1b75339b45 100644 --- a/esphome/components/mhz19/__init__.py +++ b/esphome/components/mhz19/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mhz19" diff --git a/esphome/components/mhz19/mhz19.cpp b/esphome/components/mhz19/mhz19.cpp index 707d952f83..0f623bfd72 100644 --- a/esphome/components/mhz19/mhz19.cpp +++ b/esphome/components/mhz19/mhz19.cpp @@ -5,7 +5,7 @@ namespace esphome::mhz19 { -static const char *const TAG = "mhz19"; +ESPHOME_LOG_TAG(TAG, "mhz19"); static const uint8_t MHZ19_REQUEST_LENGTH = 8; static const uint8_t MHZ19_RESPONSE_LENGTH = 9; static const uint8_t MHZ19_COMMAND_GET_PPM[] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00}; @@ -31,13 +31,7 @@ static const LogString *detection_range_to_log_string(MHZ19DetectionRange range) } #endif -uint8_t mhz19_checksum(const uint8_t *command) { - uint8_t sum = 0; - for (uint8_t i = 1; i < MHZ19_REQUEST_LENGTH; i++) { - sum += command[i]; - } - return 0xFF - sum + 0x01; -} +uint8_t mhz19_checksum(const uint8_t *command) { return -sum_bytes(command + 1, MHZ19_REQUEST_LENGTH - 1); } void MHZ19Component::setup() { if (this->abc_boot_logic_ == MHZ19_ABC_ENABLED) { 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..4daeef5059 100644 --- a/esphome/components/mhz19/sensor.py +++ b/esphome/components/mhz19/sensor.py @@ -2,6 +2,7 @@ from esphome import automation from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import sensor, uart +from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION import esphome.config_validation as cv from esphome.const import ( CONF_CO2, @@ -15,32 +16,14 @@ 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"] -CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" 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..9477ebc850 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 @@ -45,6 +44,7 @@ DOMAIN = "micro_wake_word" CONF_FEATURE_STEP_SIZE = "feature_step_size" CONF_MODELS = "models" CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" +CONF_PREFER_INTERNAL_RAM = "prefer_internal_ram" CONF_PROBABILITY_CUTOFF = "probability_cutoff" CONF_SLIDING_WINDOW_AVERAGE_SIZE = "sliding_window_average_size" CONF_SLIDING_WINDOW_SIZE = "sliding_window_size" @@ -58,17 +58,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") @@ -438,6 +427,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_VAD): _maybe_empty_vad_schema, cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean, + cv.Optional(CONF_PREFER_INTERNAL_RAM, default=False): cv.boolean, cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram, cv.Optional(CONF_MODEL): cv.invalid( f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter." @@ -531,6 +521,8 @@ async def to_code(config): cg.add(var.set_microphone_source(mic_source)) cg.add_define("USE_MICRO_WAKE_WORD") + if config[CONF_PREFER_INTERNAL_RAM]: + cg.add_define("USE_MICRO_WAKE_WORD_INTERNAL_RAM") ota.request_ota_state_listeners() if config.get(CONF_TASK_STACK_IN_PSRAM): @@ -581,6 +573,7 @@ async def to_code(config): cg.add( var.add_vad_model( prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_MICRO][CONF_TENSOR_ARENA_SIZE], @@ -593,6 +586,7 @@ async def to_code(config): model_parameters[CONF_ID], str(model_parameters[CONF_ID]), prog_arr, + len(data), quantized_probability_cutoff, sliding_window_size, manifest[KEY_WAKE_WORD], @@ -619,23 +613,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 +634,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/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index cf239be696..4ae66d69bf 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -17,7 +17,7 @@ namespace esphome::micro_wake_word { -static const char *const TAG = "micro_wake_word"; +ESPHOME_LOG_TAG(TAG, "micro_wake_word"); static const ssize_t DETECTION_QUEUE_LENGTH = 5; @@ -386,9 +386,10 @@ WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) { } #ifdef USE_MICRO_WAKE_WORD_VAD -void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { - this->vad_model_ = make_unique(model_start, probability_cutoff, sliding_window_size, tensor_arena_size); +void MicroWakeWord::add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { + this->vad_model_ = + make_unique(model_start, model_size, probability_cutoff, sliding_window_size, tensor_arena_size); } #endif @@ -471,14 +472,14 @@ void MicroWakeWord::loop() { // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, this->microphone_source_->get_audio_stream_info().get_sample_rate())) { - this->status_momentary_error("frontend_alloc", 1000); + this->status_momentary_error(1000); return; } if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state - this->status_momentary_error("task_start", 1000); + this->status_momentary_error(1000); } } break; diff --git a/esphome/components/micro_wake_word/micro_wake_word.h b/esphome/components/micro_wake_word/micro_wake_word.h index 03f4a86fd4..862d40733b 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.h +++ b/esphome/components/micro_wake_word/micro_wake_word.h @@ -93,8 +93,8 @@ class MicroWakeWord final : public Component uint8_t get_features_step_size() const { return this->features_step_size_; } #ifdef USE_MICRO_WAKE_WORD_VAD - void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + void add_vad_model(const uint8_t *model_start, size_t model_size, uint8_t probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); // Intended for the voice assistant component to fetch VAD status bool get_vad_state() { return this->vad_state_; } diff --git a/esphome/components/micro_wake_word/model_data.cpp b/esphome/components/micro_wake_word/model_data.cpp index a7326ab77a..ace0d9770a 100644 --- a/esphome/components/micro_wake_word/model_data.cpp +++ b/esphome/components/micro_wake_word/model_data.cpp @@ -10,7 +10,7 @@ namespace esphome::micro_wake_word { -static const char *const TAG = "micro_wake_word"; +ESPHOME_LOG_TAG(TAG, "micro_wake_word"); ModelData::~ModelData() { this->deallocate_(); } @@ -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/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 72984f04fb..14e6046ba0 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -4,8 +4,12 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM +#include +#endif -static const char *const TAG = "micro_wake_word"; +ESPHOME_LOG_TAG(TAG, "micro_wake_word"); namespace esphome::micro_wake_word { @@ -62,16 +66,43 @@ bool StreamingModel::load_model_() { } if (this->tensor_arena_ == nullptr) { +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + RAMAllocator preferred_allocator(RAMAllocator::PREFER_INTERNAL); + this->tensor_arena_ = preferred_allocator.allocate(this->tensor_arena_size_); + if (this->tensor_arena_ != nullptr && !esp_ptr_internal(this->tensor_arena_)) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for tensor arena; using default memory", + this->tensor_arena_size_); + } +#else this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); +#endif if (this->tensor_arena_ == nullptr) { ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); return false; } } +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (!esp_ptr_internal(this->model_start_) && this->model_copy_ == nullptr) { + RAMAllocator internal_allocator(RAMAllocator::ALLOC_INTERNAL); + this->model_copy_ = internal_allocator.make_unique_array_for_overwrite(this->model_size_); + if (this->model_copy_ == nullptr) { + ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for model; using original model data", + this->model_size_); + } else { + std::memcpy(this->model_copy_.get(), this->model_start_, this->model_size_); + } + } +#endif + if (this->interpreter_ == nullptr) { + const uint8_t *model_data = this->model_start_; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + if (this->model_copy_ != nullptr) + model_data = this->model_copy_.get(); +#endif this->interpreter_ = - make_unique(tflite::GetModel(this->model_start_), this->streaming_op_resolver_, + make_unique(tflite::GetModel(model_data), this->streaming_op_resolver_, this->tensor_arena_, this->tensor_arena_size_, this->mrv_); if (this->interpreter_->AllocateTensors() != kTfLiteOk) { ESP_LOGE(TAG, "Failed to allocate tensors for the streaming model"); @@ -176,6 +207,9 @@ size_t StreamingModel::probe_arena_size_() { void StreamingModel::unload_model() { this->interpreter_.reset(); +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_copy_.reset(); +#endif RAMAllocator arena_allocator; @@ -255,11 +289,15 @@ void StreamingModel::reset_probabilities() { this->ignore_windows_ = -MIN_SLICES_BEFORE_DETECTION; } -WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only) { +WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, + const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, + bool internal_only) { this->id_ = id; this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_average_size; @@ -291,6 +329,9 @@ WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr m // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is // never loaded. this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = this->model_data_ ? this->model_data_->size() : 0; +#endif if (this->model_start_ == nullptr) { ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str()); } @@ -354,9 +395,12 @@ DetectionEvent WakeWordModel::determine_detected() { return detection_event; } -VADModel::VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size) { +VADModel::VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size) { this->model_start_ = model_start; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + this->model_size_ = model_size; +#endif this->default_probability_cutoff_ = default_probability_cutoff; this->probability_cutoff_ = default_probability_cutoff; this->sliding_window_size_ = sliding_window_size; diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 1cb9d6eba5..730afe32bf 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -5,6 +5,7 @@ #include "preprocessor_settings.h" #include "model_data.h" +#include "esphome/core/helpers.h" #include "esphome/core/preferences.h" #include @@ -96,6 +97,10 @@ class StreamingModel { std::vector recent_streaming_probabilities_; const uint8_t *model_start_{nullptr}; +#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM + size_t model_size_{0}; + RAMUniquePtr model_copy_; +#endif uint8_t *tensor_arena_{nullptr}; uint8_t *var_arena_{nullptr}; std::unique_ptr interpreter_; @@ -108,6 +113,7 @@ class WakeWordModel final : public StreamingModel { /// @brief Constructs a wake word model object with compile-time model data /// @param id (std::string) identifier for this model /// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer + /// @param model_size (size_t) size of the compiled-in model data /// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said /// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling /// probability @@ -115,9 +121,9 @@ class WakeWordModel final : public StreamingModel { /// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena /// @param default_enabled (bool) If true, it will be enabled by default on first boot /// @param internal_only (bool) If true, the model will not be exposed to HomeAssistant as an available model - WakeWordModel(const std::string &id, const uint8_t *model_start, uint8_t default_probability_cutoff, - size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, - bool default_enabled, bool internal_only); + WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, + uint8_t default_probability_cutoff, size_t sliding_window_average_size, const std::string &wake_word, + size_t tensor_arena_size, bool default_enabled, bool internal_only); /// @brief Constructs a wake word model object with a runtime-downloaded model /// @param id (std::string) identifier for this model @@ -173,8 +179,8 @@ class WakeWordModel final : public StreamingModel { class VADModel final : public StreamingModel { public: - VADModel(const uint8_t *model_start, uint8_t default_probability_cutoff, size_t sliding_window_size, - size_t tensor_arena_size); + VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, + size_t sliding_window_size, size_t tensor_arena_size); void log_model_config() override; diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index 13c0e1f117..159dee2d20 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.button"; +ESPHOME_LOG_TAG(TAG, "micronova.button"); void MicroNovaButton::dump_config() { LOG_BUTTON("", "Micronova button", this); diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index c2e3ef8640..86b340d5e4 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,7 +3,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova"; +ESPHOME_LOG_TAG(TAG, "micronova"); static constexpr uint8_t STOVE_REPLY_SIZE = 2; static constexpr uint32_t STOVE_REPLY_TIMEOUT = 200; // ms static constexpr uint8_t WRITE_BIT = 1 << 7; // 0x80 diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 02a6de1c84..6ce2d51d7f 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.number"; +ESPHOME_LOG_TAG(TAG, "micronova.number"); void MicroNovaNumber::dump_config() { LOG_NUMBER("", "Micronova number", this); diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index 8d528145cd..f53c80fbb8 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.sensor"; +ESPHOME_LOG_TAG(TAG, "micronova.sensor"); void MicroNovaSensor::dump_config() { LOG_SENSOR("", "Micronova sensor", this); diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index d01cc57254..ba74e68eff 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.switch"; +ESPHOME_LOG_TAG(TAG, "micronova.switch"); void MicroNovaSwitch::dump_config() { LOG_SWITCH("", "Micronova switch", this); diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index 50a0d34b3a..28f755389f 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -2,7 +2,7 @@ namespace esphome::micronova { -static const char *const TAG = "micronova.text_sensor"; +ESPHOME_LOG_TAG(TAG, "micronova.text_sensor"); void MicroNovaTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Micronova text sensor", this); diff --git a/esphome/components/microphone/__init__.py b/esphome/components/microphone/__init__.py index 9a3f5b43e7..a83827ef5d 100644 --- a/esphome/components/microphone/__init__.py +++ b/esphome/components/microphone/__init__.py @@ -12,15 +12,15 @@ from esphome.const import ( CONF_ID, CONF_MICROPHONE, 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"] CODEOWNERS = ["@jesserockz", "@kahrendt"] +DOMAIN = "microphone" IS_PLATFORM_COMPONENT = True @@ -29,36 +29,12 @@ 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, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, + await automation.build_callback_automation( + var, + "add_data_callback", [(cg.std_vector.template(cg.uint8).operator("ref").operator("const"), "x")], conf, ) @@ -72,11 +48,7 @@ async def register_microphone(var: MockObj, config: ConfigType) -> None: MICROPHONE_SCHEMA = cv.Schema.extend(audio.AUDIO_COMPONENT_SCHEMA).extend( { - cv.Optional(CONF_ON_DATA): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DataTrigger), - } - ), + cv.Optional(CONF_ON_DATA): automation.validate_automation({}), } ) @@ -189,44 +161,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 deleted file mode 100644 index c28616a290..0000000000 --- a/esphome/components/microphone/automation.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "microphone.h" - -#include - -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) { - mic->add_data_callback([this](const std::vector &data) { this->trigger(data); }); - } -}; - -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/mics_4514/__init__.py b/esphome/components/mics_4514/__init__.py index e69de29bb2..9b985504b0 100644 --- a/esphome/components/mics_4514/__init__.py +++ b/esphome/components/mics_4514/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mics_4514" diff --git a/esphome/components/mics_4514/mics_4514.cpp b/esphome/components/mics_4514/mics_4514.cpp index 14a73bc15f..957cdf90ef 100644 --- a/esphome/components/mics_4514/mics_4514.cpp +++ b/esphome/components/mics_4514/mics_4514.cpp @@ -5,7 +5,9 @@ namespace esphome::mics_4514 { -static const char *const TAG = "mics_4514"; +ESPHOME_LOG_TAG(TAG, "mics_4514"); + +static constexpr uint32_t WARMUP_TIMEOUT_ID = 0; static const uint8_t SENSOR_REGISTER = 0x04; static const uint8_t POWER_MODE_REGISTER = 0x0a; @@ -18,7 +20,7 @@ void MICS4514Component::setup() { power_mode = 0x01; this->write_register(POWER_MODE_REGISTER, &power_mode, 1); delay(100); // NOLINT - this->set_timeout("warmup", 3 * 60 * 1000, [this]() { this->warmed_up_ = true; }); + this->set_timeout(WARMUP_TIMEOUT_ID, 3 * 60 * 1000, [this]() { this->warmed_up_ = true; }); this->status_set_warning(); return; } diff --git a/esphome/components/midea/__init__.py b/esphome/components/midea/__init__.py index e69de29bb2..d9bb8492b3 100644 --- a/esphome/components/midea/__init__.py +++ b/esphome/components/midea/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea" 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 07ad02d3af..da952ab87b 100644 --- a/esphome/components/midea/climate.py +++ b/esphome/components/midea/climate.py @@ -32,7 +32,7 @@ from esphome.const import ( UNIT_PERCENT, UNIT_WATT, ) -from esphome.core import CORE, coroutine +from esphome.core import CORE CODEOWNERS = ["@dudanov"] DEPENDENCIES = ["climate", "uart"] @@ -53,25 +53,6 @@ def templatize(value): return cv.Schema(ret) -def register_action(name, type_, schema): - validator = templatize(schema).extend(MIDEA_ACTION_BASE_SCHEMA) - registerer = automation.register_action( - f"midea_ac.{name}", type_, validator, synchronous=True - ) - - def decorator(func): - async def new_func(config, action_id, template_arg, args): - ac_ = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg) - cg.add(var.set_parent(ac_)) - await coroutine(func)(var, config, args) - return var - - return registerer(new_func) - - return decorator - - ALLOWED_CLIMATE_MODES = { "HEAT_COOL": ClimateMode.CLIMATE_MODE_HEAT_COOL, "COOL": ClimateMode.CLIMATE_MODE_COOL, @@ -161,16 +142,6 @@ CONFIG_SCHEMA = cv.All( ), ) -# Actions -FollowMeAction = midea_ac_ns.class_("FollowMeAction", automation.Action) -DisplayToggleAction = midea_ac_ns.class_("DisplayToggleAction", automation.Action) -SwingStepAction = midea_ac_ns.class_("SwingStepAction", automation.Action) -BeeperOnAction = midea_ac_ns.class_("BeeperOnAction", automation.Action) -BeeperOffAction = midea_ac_ns.class_("BeeperOffAction", automation.Action) -PowerOnAction = midea_ac_ns.class_("PowerOnAction", automation.Action) -PowerOffAction = midea_ac_ns.class_("PowerOffAction", automation.Action) -PowerToggleAction = midea_ac_ns.class_("PowerToggleAction", automation.Action) - MIDEA_ACTION_BASE_SCHEMA = cv.Schema( { cv.GenerateID(CONF_ID): cv.use_id(AirConditioner), @@ -187,84 +158,31 @@ MIDEA_FOLLOW_ME_SCHEMA = cv.Schema( ) -@register_action("follow_me", FollowMeAction, MIDEA_FOLLOW_ME_SCHEMA) -async def follow_me_to_code(var, config, args): - template_ = await cg.templatable(config[CONF_BEEPER], args, cg.bool_) - cg.add(var.set_beeper(template_)) - template_ = await cg.templatable(config[CONF_USE_FAHRENHEIT], args, cg.bool_) - cg.add(var.set_use_fahrenheit(template_)) - template_ = await cg.templatable(config[CONF_TEMPERATURE], args, cg.float_) - cg.add(var.set_temperature(template_)) - - -# Toggle Display action -@register_action( - "display_toggle", - DisplayToggleAction, - cv.Schema({}), +automation.register_apply_action( + "midea_ac.follow_me", + templatize(MIDEA_FOLLOW_ME_SCHEMA).extend(MIDEA_ACTION_BASE_SCHEMA), + automation.ApplyCall( + "do_follow_me({}, {}, {})", + ( + (CONF_TEMPERATURE, cg.float_), + (CONF_USE_FAHRENHEIT, cg.bool_), + (CONF_BEEPER, cg.bool_), + ), + ), ) -async def display_toggle_to_code(var, config, args): - pass - -# Swing Step action -@register_action( - "swing_step", - SwingStepAction, - cv.Schema({}), -) -async def swing_step_to_code(var, config, args): - pass - - -# Beeper On action -@register_action( - "beeper_on", - BeeperOnAction, - cv.Schema({}), -) -async def beeper_on_to_code(var, config, args): - pass - - -# Beeper Off action -@register_action( - "beeper_off", - BeeperOffAction, - cv.Schema({}), -) -async def beeper_off_to_code(var, config, args): - pass - - -# Power On action -@register_action( - "power_on", - PowerOnAction, - cv.Schema({}), -) -async def power_on_to_code(var, config, args): - pass - - -# Power Off action -@register_action( - "power_off", - PowerOffAction, - cv.Schema({}), -) -async def power_off_to_code(var, config, args): - pass - - -# Power Toggle action -@register_action( - "power_toggle", - PowerToggleAction, - cv.Schema({}), -) -async def power_inv_to_code(var, config, args): - pass +for _name, _call in ( + ("midea_ac.display_toggle", "do_display_toggle()"), + ("midea_ac.swing_step", "do_swing_step()"), + ("midea_ac.beeper_on", "do_beeper_on()"), + ("midea_ac.beeper_off", "do_beeper_off()"), + ("midea_ac.power_on", "do_power_on()"), + ("midea_ac.power_off", "do_power_off()"), + ("midea_ac.power_toggle", "do_power_toggle()"), +): + automation.register_apply_action( + _name, MIDEA_ACTION_BASE_SCHEMA, automation.ApplyCall(_call) + ) FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( @@ -296,15 +214,10 @@ async def to_code(config): cg.add(var.set_custom_presets(config[CONF_CUSTOM_PRESETS])) if CONF_CUSTOM_FAN_MODES in config: cg.add(var.set_custom_fan_modes(config[CONF_CUSTOM_FAN_MODES])) - if CONF_OUTDOOR_TEMPERATURE in config: - sens = await sensor.new_sensor(config[CONF_OUTDOOR_TEMPERATURE]) - cg.add(var.set_outdoor_temperature_sensor(sens)) - if CONF_POWER_USAGE in config: - sens = await sensor.new_sensor(config[CONF_POWER_USAGE]) - cg.add(var.set_power_sensor(sens)) - if CONF_HUMIDITY_SETPOINT in config: - sens = await sensor.new_sensor(config[CONF_HUMIDITY_SETPOINT]) - cg.add(var.set_humidity_setpoint_sensor(sens)) + sensors = sensor.sub_sensors(config) + await sensors(CONF_OUTDOOR_TEMPERATURE, var.set_outdoor_temperature_sensor) + await sensors(CONF_POWER_USAGE, var.set_power_sensor) + await sensors(CONF_HUMIDITY_SETPOINT, var.set_humidity_setpoint_sensor) # MideaUART uses the Arduino WiFi API for the network-notify frame # (WiFi auto-enables Network via dependency mapping). On ESP-IDF the # library talks to esp_wifi directly, so no library entry is needed. diff --git a/esphome/components/midea_ac/__init__.py b/esphome/components/midea_ac/__init__.py index e69de29bb2..dbff1a1186 100644 --- a/esphome/components/midea_ac/__init__.py +++ b/esphome/components/midea_ac/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea_ac" diff --git a/esphome/components/midea_ir/__init__.py b/esphome/components/midea_ir/__init__.py index e69de29bb2..01f3c67f7c 100644 --- a/esphome/components/midea_ir/__init__.py +++ b/esphome/components/midea_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "midea_ir" diff --git a/esphome/components/midea_ir/midea_ir.cpp b/esphome/components/midea_ir/midea_ir.cpp index de25c4652c..55f84e058f 100644 --- a/esphome/components/midea_ir/midea_ir.cpp +++ b/esphome/components/midea_ir/midea_ir.cpp @@ -6,7 +6,7 @@ namespace esphome::midea_ir { -static const char *const TAG = "midea_ir.climate"; +ESPHOME_LOG_TAG(TAG, "midea_ir.climate"); void ControlData::set_temp(float temp) { uint8_t min; diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 3f73f96327..b2351e23a4 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -35,6 +35,8 @@ from esphome.const import ( from esphome.core import CORE, TimePeriod from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor +DOMAIN = "mipi" + LOGGER = cv.logging.getLogger(__name__) CONF_TRANSFORMS = "transforms" @@ -606,11 +608,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 +622,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 +650,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/__init__.py b/esphome/components/mipi_dsi/__init__.py index 84dd66e2f4..ae17e00bdc 100644 --- a/esphome/components/mipi_dsi/__init__.py +++ b/esphome/components/mipi_dsi/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] +DOMAIN = "mipi_dsi" mipi_dsi_ns = cg.esphome_ns.namespace("mipi_dsi") 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 b6612038b6..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 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/guition.py b/esphome/components/mipi_dsi/models/guition.py index 914361a4ac..3ebaf53f5b 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -1,3 +1,5 @@ +from esphome.components.mipi import SLPOUT, delay + from . import DsiDriverChip # fmt: off @@ -34,6 +36,61 @@ DsiDriverChip( ], ) +# JC1060P470-V2: 2026 batch of the JC1060P470 with a different 7" panel behind the same JD9165 driver. +# The rear label shows "V2" after the material number. Timings and init sequence taken from Guition's +# reference driver JC1060P470C_I_W_Y.zip (2026-07-13), +# 1-Demo/.../JC1060P470C_I_W_Y_New_Panel/lvgl_demo_v8/src/lcd/esp_lcd_jd9165.{c,h} +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 1024x600 +# * PCLK Frequency: 52 MHz +# * DSI Lane Bit Rate: 750 Mbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=24, hsync_back_porch=136, hsync_front_porch=160) +# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=21, vsync_front_porch=12) +# ---------------------------------------------------------------------------------------------------------------------- +DsiDriverChip( + "JC1060P470-V2", + width=1024, + height=600, + hsync_back_porch=136, + hsync_pulse_width=24, + hsync_front_porch=160, + vsync_back_porch=21, + vsync_pulse_width=2, + vsync_front_porch=12, + pclk_frequency="52MHz", + lane_bit_rate="750Mbps", + color_order="RGB", + # LCD_RST is GPIO0 on the JC1060P470C_I_W_Y schematic (FPC1 pin 4); the JD9165 needs 120 ms after reset + # before it accepts the vendor table and 120 ms after SLPOUT before DISPON (Guition esp_lcd_jd9165.c); + # the framework adds another 10 ms after SLPOUT. + # SLPOUT is in the init sequence with its own delay, so the framework must not append a second one. + reset_pin=0, + no_slpout=True, + initsequence=[ + delay(120), + (0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x08), (0x0B, 0x11), + (0x20, 0x04), (0x1F, 0x05), (0x23, 0x38), (0x28, 0x18), (0x29, 0x29), (0x2A, 0x01), (0x2B, 0x29), + (0x2C, 0x01), (0x30, 0x02), (0x00, 0x05), (0x01, 0x22), (0x02, 0x08), (0x03, 0x12), (0x04, 0x16), + (0x05, 0x64), (0x06, 0x00), (0x07, 0x00), (0x08, 0x78), (0x09, 0x00), (0x0A, 0x04), + (0x0B, 0x16, 0x17, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x07, 0x07, 0x09), + (0x0C, 0x09, 0x1E, 0x1E, 0x1C, 0x1C, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0D, 0x0A, 0x05, 0x0B, 0x0D, 0x0D, 0x0D, 0x11, 0x10, 0x06, 0x06, 0x08), + (0x0E, 0x08, 0x1F, 0x1F, 0x1D, 0x1D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x0F, 0x0A, 0x05, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1D, 0x1D, 0x1F), + (0x10, 0x1F, 0x08, 0x08, 0x06, 0x06, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x11, 0x16, 0x17, 0x0D, 0x0B, 0x0D, 0x0D, 0x11, 0x10, 0x1C, 0x1C, 0x1E), + (0x12, 0x1E, 0x09, 0x09, 0x07, 0x07, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D, 0x0D), + (0x13, 0x00, 0x00, 0x00, 0x00), (0x14, 0x00, 0x00, 0x41, 0x41), (0x15, 0x00, 0x00, 0x00, 0x00), + (0x17, 0x00), (0x18, 0x85), (0x19, 0x06, 0x09), (0x1A, 0x05, 0x08), (0x1B, 0x0A, 0x04), (0x26, 0x00), + (0x27, 0x00), (0x30, 0x06), + (0x12, 0x3F, 0x25, 0x27, 0x35, 0x1D, 0x1B, 0x1B, 0x1A, 0x18, 0x0A, 0x2A, 0x21, 0x19, 0x30), + (0x13, 0x3F, 0x26, 0x27, 0x35, 0x1E, 0x1C, 0x1C, 0x1A, 0x18, 0x0B, 0x2A, 0x21, 0x19, 0x30), + (0x30, 0x0A), (0x02, 0x4F), (0x0B, 0x40), (0x30, 0x0D), (0x0D, 0x04), (0x10, 0x05), (0x11, 0x0C), + (0x12, 0x05), (0x13, 0x0C), (0x30, 0x00), + (SLPOUT,), delay(120), + ], +) + # JC4880P443 Driver Configuration (ST7701) # Using parameters from esp_lcd_st7701.h and the working full init sequence 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 3f83da7f80..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); } 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 550e1998bb..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)); @@ -246,20 +262,18 @@ class MipiSpi : public display::Display, this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) { - // Toggle D/C only while holding the bus; on boards where D/C doubles as - // another bus signal, driving it while another device owns the bus - // corrupts that device's transfer. this->enable(); this->dc_pin_->digital_write(false); this->write_cmd_addr_data(0, 0, 0, 0, &cmd, 1, 8); this->dc_pin_->digital_write(true); - this->disable(); + // hold the bus between command and data to avoid a glitch on the D/C line if (len != 0) { - this->enable(); this->write_cmd_addr_data(0, 0, 0, 0, bytes, len, 8); - this->disable(); } + this->disable(); } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE) { + // Toggle D/C only while holding the bus; works around a quirk in the CoreS3 and W5500 ethernet combination. + // See https://github.com/esphome/esphome/pull/18529 this->enable(); this->dc_pin_->digital_write(false); this->write_byte(cmd); @@ -271,12 +285,14 @@ class MipiSpi : public display::Display, this->disable(); } } else if constexpr (BUS_TYPE == BUS_TYPE_SINGLE_16) { - this->enable(); + // DC must be stable before CS as the clock is gated by CS this->dc_pin_->digital_write(false); + this->enable(); this->write_byte(cmd); - this->dc_pin_->digital_write(true); this->disable(); + this->dc_pin_->digital_write(true); for (size_t i = 0; i != len; i++) { + // must enable and disable for each byte based on empirical testing this->enable(); this->write_byte(0); this->write_byte(bytes[i]); @@ -486,7 +502,9 @@ class MipiSpi : public display::Display, bool invert_colors_{}; optional brightness_{}; const char *model_{"Unknown"}; - std::vector init_sequence_{}; + // Shared PROGMEM table + const uint8_t *init_sequence_{nullptr}; + size_t init_sequence_len_{0}; }; /** diff --git a/esphome/components/mipi_spi/models/cyd.py b/esphome/components/mipi_spi/models/cyd.py index 0a35cb4fee..f8a9673960 100644 --- a/esphome/components/mipi_spi/models/cyd.py +++ b/esphome/components/mipi_spi/models/cyd.py @@ -1,3 +1,4 @@ +from esphome.components.mipi import MODE_RGB from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER from .ili import GC9A01A, ILI9341, ILI9342, ST7789V @@ -21,8 +22,10 @@ ST7789V.extend( # fmt: off ILI9342.extend( - # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller + # ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller. + # The panel on this board is wired RGB, not the BGR default. "ESP32-2432S028-9342", + color_order=MODE_RGB, data_rate="40MHz", cs_pin={"number": 15, "ignore_strapping_warning": True}, dc_pin={"number": 2, "ignore_strapping_warning": True}, diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 187fcfd8c0..835799c262 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -649,7 +649,7 @@ DriverChip( (0xF3, 0x52, 0xA4, 0x7F, 0x33, 0x34, 0xDF), ), ) -DriverChip( +ST7735 = DriverChip( "ST7735", color_order=MODE_RGB, width=128, diff --git a/esphome/components/mipi_spi/models/lcdwiki.py b/esphome/components/mipi_spi/models/lcdwiki.py new file mode 100644 index 0000000000..6ef8b1fad7 --- /dev/null +++ b/esphome/components/mipi_spi/models/lcdwiki.py @@ -0,0 +1,86 @@ +# Init sequence sourced from LCDwiki's own published Arduino example for this board: +# https://github.com/ydedox/st77922 (Example_01_Simple_test/Simple_test/Simple_test.ino) +# It is the commented-out panel table in that file, with the 0x71 and 0xBE values from the +# active one, which is what the shipped firmware writes. + +from esphome.components.mipi import MODE_RGB, SLPOUT, DriverChip, delay +from esphome.components.spi import TYPE_QUAD +from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y + +# fmt: off +ES3C35P = DriverChip( + "ES3C35P", + width=320, + height=480, + draw_rounding=4, + transforms={CONF_MIRROR_X, CONF_MIRROR_Y}, + color_order=MODE_RGB, + invert_colors=True, + bus_mode=TYPE_QUAD, + data_rate="40MHz", # datasheet caps the write clock at 62.5MHz + cs_pin=10, + reset_delay=120, + no_slpout=True, # SLPOUT is in the init sequence, the panel needs 120ms after it + requires={"psram"}, + initsequence=( + (0xF1, 0x00), + (0x60, 0x00, 0x00, 0x00), + (0x65, 0x80), + (0x79, 0x06), + (0x7B, 0x00, 0x08, 0x08), + (0x80, 0x55, 0x62, 0x2F, 0x17, 0xF0, 0x52, 0x70, 0xD2, 0x52, 0x62, 0xEA), + (0x81, 0x26, 0x52, 0x72, 0x27), + (0x84, 0x92, 0x25), + (0x87, 0x10, 0x10, 0x58, 0x00, 0x02, 0x3A), + (0x88, 0x00, 0x00, 0x2C, 0x10, 0x04, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x06), + (0x89, 0x00, 0x00, 0x00), + (0x8A, 0x13, 0x00, 0x2C, 0x00, 0x00, 0x2C, 0x10, 0x10, 0x00, 0x3E, 0x19), + (0x8B, 0x15, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x97, 0x8E), + (0x8C, 0x1D, 0xB1, 0xB1, 0x44, 0x96, 0x2C, 0x10, 0x50, 0x0F, 0x01, 0xC5, 0x12, 0x09), + (0x8D, 0x0C), + (0x8E, 0x33, 0x01, 0x0C, 0x13, 0x01, 0x01), + (0xB3, 0x00, 0x30), + (0x71, 0xD0), + (0x66, 0x02, 0x3F), + (0xBE, 0x26, 0x00, 0x9D), + (0x70, 0x01, 0xA6, 0x11, 0x40, 0xE0, 0x00, 0x11, 0x60, 0x11, 0x00, 0x00, 0x1A), + (0x90, 0x04, 0x04, 0x55, 0x74, 0x00, 0x40, 0x43, 0x2D, 0x2D), + (0x91, 0x04, 0x04, 0x55, 0x75, 0x00, 0x40, 0x42, 0x2D, 0x2D), + (0x92, 0x04, 0x44, 0x55, 0xC0, 0x06, 0x00, 0x07, 0x05, 0x90, 0x2D), + (0x93, 0x04, 0x43, 0x11, 0x00, 0x00, 0x00, 0x00, 0x05, 0x90, 0x2D), + (0x94, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), + (0x95, 0x96, 0x16, 0x00, 0x00, 0xFF), + (0x96, 0x44, 0x53, 0x03, 0x12, 0x23, 0x24, 0x06, 0x05, 0x9A, 0x2D, 0x00, 0x44), + (0x97, 0x44, 0x53, 0x47, 0x56, 0x20, 0x20, 0x02, 0x01, 0x9A, 0x2D, 0x00, 0x44), + (0xBA, 0x55, 0x9A, 0x2D, 0x9A, 0x2D), + (0x9A, 0x40, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9B, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00), + (0x9C, 0x5C, 0x12, 0x00, 0x00, 0x10, 0x12, 0x00, 0x00, 0x10, 0x02, 0x00, 0x00, 0x00), + (0x9D, 0x8A, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0x9E, 0x51, 0x00, 0x00, 0x00, 0x80, 0x1E, 0x01), + (0xB4, 0x1D, 0x1C, 0x1E, 0x0B, 0x14, 0x02, 0x13, 0x09, 0x1E, 0x00, 0x1E, 0x10), + (0xB5, 0x1D, 0x1C, 0x1E, 0x0A, 0x15, 0x03, 0x11, 0x08, 0x1E, 0x01, 0x1E, 0x12), + (0xB6, 0x77, 0x77, 0x00, 0x0A, 0xFF, 0x0A, 0xFF), + (0x86, 0xC6, 0x04, 0xB1, 0x02, 0x58, 0x12, 0x58, 0x0C, 0x13, 0x01, 0xA5, 0x00, 0xA5, 0xA5), + (0xB7, 0x07, 0x0A, 0x0E, 0x06, 0x05, 0x03, 0x2B, 0x03, 0x03, 0x42, 0x07, 0x10, 0x10, 0x2E, 0x3F, 0x0D), + (0xB8, 0x07, 0x0A, 0x0D, 0x05, 0x05, 0x02, 0x2B, 0x02, 0x03, 0x42, 0x06, 0x10, 0x0F, 0x2E, 0x3F, 0x0D), + (0xB9, 0x23, 0x23), + (0xBF, 0x10, 0x14, 0x14, 0x0B, 0x0B, 0x0B), + (0xF2, 0x00), + (0x73, 0x04, 0xDA, 0x12, 0x54, 0x47), + (0x77, 0x6B, 0x5B, 0xFD, 0xC3, 0xC5), + (0x7A, 0x15, 0x27), + (0x7B, 0x04, 0x57), + (0x7E, 0x01, 0x0E), + (0xBF, 0x36), + (0xE3, 0x40, 0x40), + (0xF0, 0x00), + (0xD0, 0x00), + (SLPOUT,), + delay(120), + ), +) +# fmt: on + +# The speakerless variant uses the same display +ES3C35P.extend("ES3C35P-NS") diff --git a/esphome/components/mipi_spi/models/m5stack.py b/esphome/components/mipi_spi/models/m5stack.py index a54bd19d88..f62bee5f60 100644 --- a/esphome/components/mipi_spi/models/m5stack.py +++ b/esphome/components/mipi_spi/models/m5stack.py @@ -4,6 +4,7 @@ from esphome.components.mipi import ( GMCTRP1, IDMOFF, IFMODE, + MODE_BGR, PWCTR1, PWCTR2, SETEXTC, @@ -11,7 +12,7 @@ from esphome.components.mipi import ( DriverChip, ) -from .ili import ILI9341, ST7789V +from .ili import ILI9341, ST7735, ST7789V # fmt: off DriverChip( @@ -71,3 +72,23 @@ GC9107.extend( cs_pin=14, requires={"psram"}, ) + +# M5Stack AtomS3R (ST7735 revision - May 14, 2026+) +ST7735.extend( + "M5STACK-ATOMS3R-ST7735", + native_width=132, + native_height=132, + width=128, + height=128, + offset_width=2, + offset_height=1, + pad_width=2, + pad_height=3, + data_rate="40MHz", + color_order=MODE_BGR, + invert_colors=True, + reset_pin=48, + dc_pin=42, + cs_pin=14, + requires={"psram"}, +) 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/mistral_ir/__init__.py b/esphome/components/mistral_ir/__init__.py new file mode 100644 index 0000000000..deadd224d5 --- /dev/null +++ b/esphome/components/mistral_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mistral_ir" diff --git a/esphome/components/mistral_ir/climate.py b/esphome/components/mistral_ir/climate.py new file mode 100644 index 0000000000..cc152ca85d --- /dev/null +++ b/esphome/components/mistral_ir/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@lerhum"] + +mistral_ir_ns = cg.esphome_ns.namespace("mistral_ir") +MistralIR = mistral_ir_ns.class_("MistralIR", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MistralIR) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("aeha") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/mistral_ir/mistral_ir.cpp b/esphome/components/mistral_ir/mistral_ir.cpp new file mode 100644 index 0000000000..2eafcb348a --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.cpp @@ -0,0 +1,135 @@ +#include "mistral_ir.h" +#include "esphome/components/remote_base/aeha_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::mistral_ir { + +ESPHOME_LOG_TAG(TAG, "mistral_ir.climate"); + +// Fixed bytes of every frame; bytes 1, 3, 4, 5, 6 and 11 are filled in per state +static const uint8_t FRAME_TEMPLATE[MISTRAL_FRAME_SIZE] PROGMEM = {0x56, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xE8, 0x00, 0x60, 0x00}; +static const uint8_t FIXED_BYTES[] PROGMEM = {0, 2, 7, 8, 9, 10}; +static const uint8_t CHECKSUM_BYTES[] PROGMEM = {1, 3, 4, 5, 6, 8}; + +constexpr uint8_t MISTRAL_POWER_OFF = 0x00; +constexpr uint8_t MISTRAL_POWER_ON = 0x20; +constexpr uint8_t MISTRAL_TEMP_OFFSET = 0x9F; +constexpr uint8_t MISTRAL_MODE_AUTO = 0x10; + +// The tables live in flash, so entries are copied out before use +struct ModeCode { + climate::ClimateMode mode; + uint8_t code; +}; +static const ModeCode MODES[] PROGMEM = { + {climate::CLIMATE_MODE_COOL, 0xC0}, + {climate::CLIMATE_MODE_DRY, 0x40}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x80}, + {climate::CLIMATE_MODE_HEAT, 0x60}, + {climate::CLIMATE_MODE_HEAT_COOL, MISTRAL_MODE_AUTO}, +}; + +struct FanCode { + climate::ClimateFanMode fan; + uint8_t byte1; + uint8_t byte6; +}; +static const FanCode FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0xA4, 0x00}, + {climate::CLIMATE_FAN_LOW, 0x44, 0x40}, + {climate::CLIMATE_FAN_MEDIUM, 0x24, 0xC0}, + {climate::CLIMATE_FAN_HIGH, 0xA4, 0xA0}, +}; + +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +template static optional find_entry(const T (&table)[N], Pred pred) { + for (const T &slot : table) { + T entry = load_entry(slot); + if (pred(entry)) + return entry; + } + return {}; +} + +static uint8_t compute_checksum(const uint8_t *frame) { + uint8_t sum = 0; + for (const uint8_t &idx : CHECKSUM_BYTES) + sum += reverse_bits(frame[progmem_read_byte(&idx)]); + return reverse_bits(sum); +} + +static bool is_valid_frame(const uint8_t *frame) { + for (const uint8_t &slot : FIXED_BYTES) { + const uint8_t idx = progmem_read_byte(&slot); + if (frame[idx] != progmem_read_byte(&FRAME_TEMPLATE[idx])) + return false; + } + return frame[11] == compute_checksum(frame); +} + +void MistralIR::transmit_state() { + const auto mode = find_entry(MODES, [this](const ModeCode &m) { return m.mode == this->mode; }); + const climate::ClimateFanMode fan_mode = this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO); + const FanCode fan = + find_entry(FANS, [fan_mode](const FanCode &f) { return f.fan == fan_mode; }).value_or(load_entry(FANS[0])); + const uint8_t temp = lroundf(clamp(this->target_temperature, MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX)); + + uint8_t frame[MISTRAL_FRAME_SIZE]; + progmem_memcpy(frame, FRAME_TEMPLATE, sizeof(frame)); + frame[1] = fan.byte1; + frame[3] = this->mode == climate::CLIMATE_MODE_OFF ? MISTRAL_POWER_OFF : MISTRAL_POWER_ON; + frame[4] = mode.has_value() ? mode->code : MISTRAL_MODE_AUTO; + frame[5] = reverse_bits(static_cast(MISTRAL_TEMP_OFFSET - temp)); + frame[6] = fan.byte6; + frame[11] = compute_checksum(frame); + + auto transmit = this->transmitter_->transmit(); + remote_base::AEHAProtocol().encode(transmit.get_data(), MISTRAL_ADDRESS, frame, sizeof(frame)); + transmit.perform(); +} + +bool MistralIR::on_receive(remote_base::RemoteReceiveData data) { + auto aeha = remote_base::AEHAProtocol().decode(data); + if (!aeha.has_value() || aeha->address != MISTRAL_ADDRESS || aeha->data.size() != MISTRAL_FRAME_SIZE) + return false; + + const uint8_t *frame = aeha->data.data(); + if (!is_valid_frame(frame)) + return false; + + if (frame[3] == MISTRAL_POWER_OFF) { + this->mode = climate::CLIMATE_MODE_OFF; + this->publish_state(); + return true; + } + if (frame[3] != MISTRAL_POWER_ON) + return false; + + // Decode everything before touching the state so an unknown code leaves it untouched + const auto mode = find_entry(MODES, [frame](const ModeCode &m) { return m.code == frame[4]; }); + const auto fan = find_entry(FANS, [frame](const FanCode &f) { return f.byte1 == frame[1] && f.byte6 == frame[6]; }); + if (!mode.has_value() || !fan.has_value()) + return false; + + this->mode = mode->mode; + this->target_temperature = + clamp(MISTRAL_TEMP_OFFSET - reverse_bits(frame[5]), MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX); + this->fan_mode = fan->fan; + + ESP_LOGV(TAG, "Received Mistral frame: mode=%d temp=%.0f fan=%d", static_cast(this->mode), + this->target_temperature, static_cast(fan->fan)); + this->publish_state(); + return true; +} + +} // namespace esphome::mistral_ir diff --git a/esphome/components/mistral_ir/mistral_ir.h b/esphome/components/mistral_ir/mistral_ir.h new file mode 100644 index 0000000000..172af1d6a2 --- /dev/null +++ b/esphome/components/mistral_ir/mistral_ir.h @@ -0,0 +1,38 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::mistral_ir { + +// Temperature range, Celsius +constexpr uint8_t MISTRAL_TEMP_MIN = 16; +constexpr uint8_t MISTRAL_TEMP_MAX = 30; + +constexpr uint16_t MISTRAL_ADDRESS = 0x322C; +constexpr size_t MISTRAL_FRAME_SIZE = 12; + +/* Reverse-engineered protocol for a "Mistral" (Belgian brand, model MHFE1126A / MR112C) split AC unit + from the 2000-2010 era, likely a rebadged OEM design. It is carried over AEHA IR timing (address + 0x322C) but does not match any protocol already implemented in ESPHome, so it is decoded/encoded + directly here rather than through the generic AEHA helper. + + Frame layout (12 bytes): 56 xx 08 xx xx xx xx 00 E8 00 60 xx + byte[1] / byte[6] -- fan speed + byte[3] -- power: 0x20 on, 0x00 off + byte[4] -- mode + byte[5] -- target temperature (bit-reversed offset from 0x9F) + byte[11] -- checksum: bit-reverse the sum of bit-reversed bytes 1, 3, 4, 5, 6, 8 +*/ +class MistralIR : public climate_ir::ClimateIR { + public: + MistralIR() + : climate_ir::ClimateIR(MISTRAL_TEMP_MIN, MISTRAL_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; +}; + +} // namespace esphome::mistral_ir diff --git a/esphome/components/mitsubishi/__init__.py b/esphome/components/mitsubishi/__init__.py index e69de29bb2..05ae0f0543 100644 --- a/esphome/components/mitsubishi/__init__.py +++ b/esphome/components/mitsubishi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mitsubishi" diff --git a/esphome/components/mitsubishi/climate.py b/esphome/components/mitsubishi/climate.py index 2d38351898..1fbe1d67cf 100644 --- a/esphome/components/mitsubishi/climate.py +++ b/esphome/components/mitsubishi/climate.py @@ -20,6 +20,7 @@ SETFANMODE = { CONF_SUPPORTS_DRY = "supports_dry" CONF_SUPPORTS_FAN_ONLY = "supports_fan_only" +CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility" CONF_HORIZONTAL_DEFAULT = "horizontal_default" HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections") @@ -43,18 +44,22 @@ VERTICAL_DIRECTIONS = { "down": VerticalDirections.VERTICAL_DIRECTION_DOWN, } +CONF_VERTICAL_VANES = "vertical_vanes" + CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).extend( { cv.Optional(CONF_SET_FAN_MODE, default="3levels"): cv.enum(SETFANMODE), cv.Optional(CONF_SUPPORTS_DRY, default=False): cv.boolean, cv.Optional(CONF_SUPPORTS_FAN_ONLY, default=False): cv.boolean, + cv.Optional(CONF_FAHRENHEIT_COMPATIBILITY, default=False): cv.boolean, cv.Optional(CONF_HORIZONTAL_DEFAULT, default="middle"): cv.enum( HORIZONTAL_DIRECTIONS ), cv.Optional(CONF_VERTICAL_DEFAULT, default="middle"): cv.enum( VERTICAL_DIRECTIONS ), + cv.Optional(CONF_VERTICAL_VANES, default=1): cv.int_range(1, 2), } ) @@ -65,5 +70,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_fan_mode(config[CONF_SET_FAN_MODE])) cg.add(var.set_supports_dry(config[CONF_SUPPORTS_DRY])) cg.add(var.set_supports_fan_only(config[CONF_SUPPORTS_FAN_ONLY])) + cg.add(var.set_fahrenheit_compatibility(config[CONF_FAHRENHEIT_COMPATIBILITY])) cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT])) cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT])) + cg.add(var.set_vertical_vanes(config[CONF_VERTICAL_VANES])) diff --git a/esphome/components/mitsubishi/mitsubishi.cpp b/esphome/components/mitsubishi/mitsubishi.cpp index 9cb355fe9c..9fba222d63 100644 --- a/esphome/components/mitsubishi/mitsubishi.cpp +++ b/esphome/components/mitsubishi/mitsubishi.cpp @@ -1,10 +1,11 @@ #include "mitsubishi.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::mitsubishi { -static const char *const TAG = "mitsubishi.climate"; +ESPHOME_LOG_TAG(TAG, "mitsubishi.climate"); // IR frame size for Mitsubishi climate static constexpr size_t MITSUBISHI_FRAME_SIZE = 18; @@ -26,6 +27,7 @@ const uint8_t MITSUBISHI_WIDE_VANE_SWING = 0xC0; const uint8_t MITSUBISHI_FAN_AUTO = 0x00; +const uint8_t MITSUBISHI_VERTICAL_VANE_MASK = 0x38; // Bits 3,4,5 const uint8_t MITSUBISHI_VERTICAL_VANE_SWING = 0x38; // const uint8_t MITSUBISHI_AUTO = 0x80; @@ -51,7 +53,33 @@ const uint8_t MITSUBISHI_BYTE02 = 0x26; const uint8_t MITSUBISHI_BYTE03 = 0x01; const uint8_t MITSUBISHI_BYTE04 = 0x00; const uint8_t MITSUBISHI_BYTE13 = 0x00; -const uint8_t MITSUBISHI_BYTE16 = 0x00; + +// Byte 7 codes for 61°F through 88°F. Bit 4 is the half degree bit Mitsubishi uses so every Fahrenheit degree +// maps to its own code. +static const uint8_t FAHRENHEIT_CODES[] PROGMEM = {0x00, 0x10, 0x01, 0x11, 0x02, 0x12, 0x03, 0x04, 0x05, 0x15, + 0x06, 0x16, 0x07, 0x17, 0x08, 0x18, 0x09, 0x19, 0x0a, 0x1a, + 0x0b, 0x1b, 0x0c, 0x1c, 0x0d, 0x1d, 0x0e, 0x0f}; +static constexpr uint8_t FAHRENHEIT_MIN = 61; +static constexpr uint8_t MITSUBISHI_HALF_DEGREE = 0x10; + +static uint8_t fahrenheit_code(float celsius) { + int fahrenheit = (int) roundf(celsius_to_fahrenheit(celsius)); + size_t index = clamp(fahrenheit - FAHRENHEIT_MIN, 0, sizeof(FAHRENHEIT_CODES) - 1); + return progmem_read_byte(&FAHRENHEIT_CODES[index]); +} + +// In Fahrenheit mode a code from the table decodes to the exact Fahrenheit value. Any other code, and every code in +// Celsius mode, is a whole degree plus the half degree bit, which a remote set to Fahrenheit also sets. +static float decode_temperature(uint8_t code, bool fahrenheit) { + if (fahrenheit) { + for (size_t i = 0; i < sizeof(FAHRENHEIT_CODES); i++) { + if (code == progmem_read_byte(&FAHRENHEIT_CODES[i])) { + return fahrenheit_to_celsius(FAHRENHEIT_MIN + i); + } + } + } + return MITSUBISHI_TEMP_MIN + (code & 0x0F) + ((code & MITSUBISHI_HALF_DEGREE) ? 0.5f : 0.0f); +} void MitsubishiClimate::transmit_state() { // Byte 0-4: Constant: 0x23, 0xCB, 0x26, 0x01, 0x00 @@ -72,7 +100,7 @@ void MitsubishiClimate::transmit_state() { // Byte 13: Constant 0x00 // Byte 14: HVAC specfic, i.e. ECONO COOL, CLEAN MODE, always 0x00 // Byte 15: HVAC specfic, i.e. POWERFUL, SMART SET, PLASMA, always 0x00 - // Byte 16: Constant 0x00 + // Byte 16: Left vane control (for specific models). Constants match right vane controls. // Byte 17: Checksum: SUM[Byte0...Byte16] uint8_t remote_state[18] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; @@ -113,6 +141,8 @@ void MitsubishiClimate::transmit_state() { // Temperature if (this->mode == climate::CLIMATE_MODE_DRY) { remote_state[7] = 24 - MITSUBISHI_TEMP_MIN; // Remote sends always 24°C if "Dry" mode is selected + } else if (this->fahrenheit_compatibility_) { + remote_state[7] = fahrenheit_code(this->target_temperature); } else { remote_state[7] = (uint8_t) roundf( clamp(this->target_temperature, MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX) - MITSUBISHI_TEMP_MIN); @@ -184,7 +214,7 @@ void MitsubishiClimate::transmit_state() { break; } - ESP_LOGD(TAG, "default_vertical_direction_: %02X", this->default_vertical_direction_); + ESP_LOGD(TAG, "Vertical default: 0x%02X, vanes: %u", this->default_vertical_direction_, this->vertical_vanes_); // Special modes switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) { @@ -206,11 +236,14 @@ void MitsubishiClimate::transmit_state() { break; } - // Checksum - for (int i = 0; i < 17; i++) { - remote_state[17] += remote_state[i]; + if (this->vertical_vanes_ > 1) { + // Heads with two vertical vanes carry the left vane in byte 16; it follows the right one, presets included + remote_state[16] = remote_state[9] & MITSUBISHI_VERTICAL_VANE_MASK; } + // Checksum + remote_state[17] = sum_bytes(remote_state, 17); + ESP_LOGD(TAG, "sending: %02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X,%02X", remote_state[0], remote_state[1], remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], remote_state[7], remote_state[8], remote_state[9], remote_state[10], remote_state[11], @@ -266,11 +299,11 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } state_frame[pos] = byte; - // Check Header && Footer + // Check Header && Footer. Byte 16 only ever carries the left vane position (bits 3,4,5). if ((pos == 0 && byte != MITSUBISHI_BYTE00) || (pos == 1 && byte != MITSUBISHI_BYTE01) || (pos == 2 && byte != MITSUBISHI_BYTE02) || (pos == 3 && byte != MITSUBISHI_BYTE03) || (pos == 4 && byte != MITSUBISHI_BYTE04) || (pos == 13 && byte != MITSUBISHI_BYTE13) || - (pos == 16 && byte != MITSUBISHI_BYTE16)) { + (pos == 16 && (byte & ~MITSUBISHI_VERTICAL_VANE_MASK) != 0)) { ESP_LOGV(TAG, "Bytes 0,1,2,3,4,13 or 16 fail - invalid value"); return false; } @@ -300,7 +333,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } // Temp - this->target_temperature = state_frame[7] + MITSUBISHI_TEMP_MIN; + this->target_temperature = decode_temperature(state_frame[7], this->fahrenheit_compatibility_); // Fan uint8_t fan = state_frame[9] & 0x07; //(Bit 0,1,2 = Speed) @@ -332,7 +365,9 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) { } // Vertical Vane - uint8_t vertical_vane = state_frame[9] & 0x38; // Bits 3,4,5 + // On dual vane heads, the left vane is in [16] and right vane is in [9]. Left is ignored here because + // the swing_mode enum doesn't convey that level of detail. + uint8_t vertical_vane = state_frame[9] & MITSUBISHI_VERTICAL_VANE_MASK; switch (vertical_vane) { case MITSUBISHI_VERTICAL_VANE_SWING: if (this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL) { diff --git a/esphome/components/mitsubishi/mitsubishi.h b/esphome/components/mitsubishi/mitsubishi.h index 269d39b06b..493661f9a6 100644 --- a/esphome/components/mitsubishi/mitsubishi.h +++ b/esphome/components/mitsubishi/mitsubishi.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/core/helpers.h" #include @@ -9,6 +10,8 @@ namespace esphome::mitsubishi { // Temperature const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius const uint8_t MITSUBISHI_TEMP_MAX = 31; // Celsius +// Remotes set to Fahrenheit go up to 88°F, one step past the Celsius range +static constexpr float MITSUBISHI_TEMP_MAX_FAHRENHEIT = fahrenheit_to_celsius(88.0f); // Fan mode enum SetFanMode : uint8_t { @@ -55,6 +58,12 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_supports_fan_only(bool supports_fan_only) { this->set_mode_supported_(climate::CLIMATE_MODE_FAN_ONLY, supports_fan_only); } + void set_fahrenheit_compatibility(bool fahrenheit_compatibility) { + this->fahrenheit_compatibility_ = fahrenheit_compatibility; + if (fahrenheit_compatibility) { + this->maximum_temperature_ = MITSUBISHI_TEMP_MAX_FAHRENHEIT; + } + } void set_fan_mode(SetFanMode fan_mode) { this->fan_mode_ = fan_mode; @@ -71,6 +80,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { void set_vertical_default(VerticalDirection vertical_direction) { this->default_vertical_direction_ = vertical_direction; } + void set_vertical_vanes(uint8_t vertical_vanes) { this->vertical_vanes_ = vertical_vanes; } protected: // Transmit via IR the state of this climate controller. @@ -80,9 +90,11 @@ class MitsubishiClimate final : public climate_ir::ClimateIR { bool parse_state_frame_(const uint8_t frame[]); SetFanMode fan_mode_; + bool fahrenheit_compatibility_{false}; HorizontalDirection default_horizontal_direction_; VerticalDirection default_vertical_direction_; + uint8_t vertical_vanes_{1}; }; } // namespace esphome::mitsubishi diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 9502773616..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 from esphome.cpp_generator import MockObj -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType CODEOWNERS = ["@crnjan"] DEPENDENCIES = ["uart"] @@ -46,18 +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), -) - CONFIG_SCHEMA = ( cv.Schema( @@ -143,40 +130,17 @@ 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_ACTION_SCHEMA = cv.Schema( diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h deleted file mode 100644 index f0bd771fd5..0000000000 --- a/esphome/components/mitsubishi_cn105/automation.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "mitsubishi_cn105_component.h" - -#include "esphome/core/automation.h" - -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(); } -}; - -} // 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.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 3d30d1a25f..3261d9d569 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -3,12 +3,11 @@ #include #include #include -#include #include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { -static const char *const TAG = "mitsubishi_cn105.driver"; +ESPHOME_LOG_TAG(TAG, "mitsubishi_cn105.driver"); static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; @@ -31,7 +30,7 @@ static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { - return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); + return static_cast(0xFC - sum_bytes(bytes, length)); } template diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 53b8c21de6..70c5d479a3 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -4,7 +4,7 @@ namespace esphome::mitsubishi_cn105 { -static const char *const TAG = "mitsubishi_cn105.climate"; +ESPHOME_LOG_TAG(TAG, "mitsubishi_cn105.climate"); static constexpr std::array MODE_MAP{ std::pair{MitsubishiCN105::Mode::AUTO, climate::CLIMATE_MODE_HEAT_COOL}, 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/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index e2a6ee05af..e3222ecf53 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -6,7 +6,7 @@ namespace esphome::mitsubishi_cn105 { -static const char *const TAG = "mitsubishi_cn105"; +ESPHOME_LOG_TAG(TAG, "mitsubishi_cn105"); void MitsubishiCN105Component::dump_config() { ESP_LOGCONFIG(TAG, "Mitsubishi CN105:"); diff --git a/esphome/components/mixer/__init__.py b/esphome/components/mixer/__init__.py index e69de29bb2..5ea96da4d2 100644 --- a/esphome/components/mixer/__init__.py +++ b/esphome/components/mixer/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mixer" diff --git a/esphome/components/mixer/speaker/__init__.py b/esphome/components/mixer/speaker/__init__.py index 26619f35a7..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,9 +142,8 @@ async def to_code(config: ConfigType) -> None: cg.add(var.add_source_speaker(source_speaker)) -@automation.register_action( +automation.register_apply_action( "mixer_speaker.apply_ducking", - DuckingApplyAction, cv.Schema( { cv.GenerateID(): cv.use_id(SourceSpeaker), @@ -162,20 +155,8 @@ async def to_code(config: ConfigType) -> None: ), } ), - synchronous=True, + automation.ApplyCall( + "apply_ducking({}, {})", + ((CONF_DECIBEL_REDUCTION, cg.uint8), (CONF_DURATION, cg.uint32)), + ), ) -async def ducking_set_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - var = cg.new_Pvariable(action_id, template_arg) - await cg.register_parented(var, config[CONF_ID]) - decibel_reduction = await cg.templatable( - config[CONF_DECIBEL_REDUCTION], args, cg.uint8 - ) - cg.add(var.set_decibel_reduction(decibel_reduction)) - duration = await cg.templatable(config[CONF_DURATION], args, cg.uint32) - cg.add(var.set_duration(duration)) - return var diff --git a/esphome/components/mixer/speaker/automation.h b/esphome/components/mixer/speaker/automation.h deleted file mode 100644 index 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 41b7123269..23867aef91 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -24,7 +24,7 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200; static const size_t TASK_STACK_SIZE = 4096; -static const char *const TAG = "speaker_mixer"; +ESPHOME_LOG_TAG(TAG, "speaker_mixer"); // Event bits for SourceSpeaker command processing enum SourceSpeakerEventBits : uint32_t { @@ -359,7 +359,7 @@ void MixerSpeaker::loop() { xEventGroupClearBits(this->event_group_, MIXER_TASK_COMMAND_START); } else { ESP_LOGE(TAG, "Failed to start; retrying in 1 second"); - this->status_momentary_error("failure", 1000); + this->status_momentary_error(1000); return; } } diff --git a/esphome/components/mixer/speaker/mixer_speaker.h b/esphome/components/mixer/speaker/mixer_speaker.h index 494443d695..04b0cb94f4 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.h +++ b/esphome/components/mixer/speaker/mixer_speaker.h @@ -107,12 +107,12 @@ class SourceSpeaker final : public speaker::Speaker, public Component { bool stop_gracefully_{false}; bool pause_state_{false}; + std::atomic has_contributed_{false}; // Tracks if source has contributed during this session esp_audio_libs::gain::GainRamp ducking_ramp_; std::atomic pending_playback_frames_{0}; std::atomic playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing - std::atomic has_contributed_{false}; // Tracks if source has contributed during this session EventGroupHandle_t event_group_{nullptr}; uint32_t stopping_start_ms_{0}; diff --git a/esphome/components/mk2pvrouter/__init__.py b/esphome/components/mk2pvrouter/__init__.py index d00b4ce8d0..7df1f7104f 100644 --- a/esphome/components/mk2pvrouter/__init__.py +++ b/esphome/components/mk2pvrouter/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@FredM67"] DEPENDENCIES = ["uart"] +DOMAIN = "mk2pvrouter" mk2pvrouter_ns = cg.esphome_ns.namespace("mk2pvrouter") Mk2PVRouter = mk2pvrouter_ns.class_("Mk2PVRouter", cg.Component, uart.UARTDevice) 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..61ecb23743 --- /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 { + +ESPHOME_LOG_TAG(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..98dd0b74ec 100644 --- a/esphome/components/mk2pvrouter/mk2pvrouter.cpp +++ b/esphome/components/mk2pvrouter/mk2pvrouter.cpp @@ -1,10 +1,11 @@ #include "mk2pvrouter.h" #include "esphome/core/log.h" +#include #include namespace esphome::mk2pvrouter { -static const char *const TAG = "mk2pvrouter"; +ESPHOME_LOG_TAG(TAG, "mk2pvrouter"); constexpr uint8_t START_FRAME = 0x2; constexpr uint8_t END_FRAME = 0x3; @@ -18,8 +19,8 @@ constexpr uint8_t CRC_OFFSET = 0x20; // Extracts a TAB-delimited field from [buf_start, buf_end) into dest. // Returns the field length, or 0 if no TAB was found, or the (uncopied) field // length if it's >= max_len. -static size_t get_field(char *dest, const char *buf_start, const char *buf_end, size_t max_len) { - const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); +static size_t get_field(char *dest, const uint8_t *buf_start, const uint8_t *buf_end, size_t max_len) { + const auto *const field_end = static_cast(memchr(buf_start, TAB, buf_end - buf_start)); if (!field_end) return 0; const size_t len = field_end - buf_start; @@ -33,22 +34,15 @@ static size_t get_field(char *dest, const char *buf_start, const char *buf_end, return len; } -// Calculates the CRC (checksum) for a given group of characters. -uint8_t Mk2PVRouter::calculate_crc_(const char *grp, size_t grp_len) { - uint8_t crc_tmp{0}; - const auto effective_len = grp_len - CRC_SUFFIX_LEN; - for (size_t i = 0; i < effective_len; i++) { - crc_tmp += grp[i]; - } - crc_tmp &= CRC_MASK; - crc_tmp += CRC_OFFSET; - return crc_tmp; +// Calculates the CRC (checksum) for a given group of bytes. +uint8_t Mk2PVRouter::calculate_crc_(const uint8_t *grp, size_t grp_len) { + return (sum_bytes(grp, grp_len - CRC_SUFFIX_LEN) & CRC_MASK) + CRC_OFFSET; } // Verifies the CRC of a group against its trailing CRC byte. -bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { +bool Mk2PVRouter::check_crc_(const uint8_t *grp, const uint8_t *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; } @@ -64,7 +58,7 @@ bool Mk2PVRouter::check_crc_(const char *grp, const char *grp_end) { } // Validates, parses, and publishes a single tag/value group. -void Mk2PVRouter::process_group_(const char *grp, const char *grp_end) { +void Mk2PVRouter::process_group_(const uint8_t *grp, const uint8_t *grp_end) { if (!this->check_crc_(grp, grp_end)) return; @@ -89,9 +83,9 @@ bool Mk2PVRouter::read_chars_until_(bool drop, uint8_t c) { size_t j{0}; while (this->available() > 0 && j++ < MAX_ITERATIONS) { - const auto received = this->read(); - if (received < 0) - continue; + uint8_t received; + if (!this->read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -133,10 +127,10 @@ void Mk2PVRouter::loop() { // Each group: 0xa(LF) | Tag | 0x9(TAB) | Data | 0x9(TAB) | CRC | 0xd(CR) // CRC is computed over "Tag | TAB | Data | TAB". - while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { + while ((buf_finger = static_cast(memchr(buf_finger, LINE_FEED, buf_end - buf_finger))) != nullptr) { ++buf_finger; // Skip LF to the start of the group. - auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); + auto *const grp_end = static_cast(memchr(buf_finger, CARRIAGE_RETURN, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; diff --git a/esphome/components/mk2pvrouter/mk2pvrouter.h b/esphome/components/mk2pvrouter/mk2pvrouter.h index fc23cf49e8..061a9a22e9 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); @@ -57,12 +57,12 @@ class Mk2PVRouter final : public Component, public uart::UARTDevice { State state_{State::WAITING_FOR_START}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; - char buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding + uint8_t buf_[MAX_BUF_SIZE]; // Large buffer last to reduce padding bool read_chars_until_(bool drop, uint8_t c); - uint8_t calculate_crc_(const char *grp, size_t grp_len); - bool check_crc_(const char *grp, const char *grp_end); - void process_group_(const char *grp, const char *grp_end); + uint8_t calculate_crc_(const uint8_t *grp, size_t grp_len); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); + void process_group_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const char *tag, const char *val); }; } // namespace esphome::mk2pvrouter 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..92e2ea715c 100644 --- a/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp +++ b/esphome/components/mk2pvrouter/sensor/mk2pvrouter_sensor.cpp @@ -3,9 +3,10 @@ namespace esphome::mk2pvrouter { -static const char *const TAG = "mk2pvrouter_sensor"; +ESPHOME_LOG_TAG(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..66e6ba0a3b --- /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 { + +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/mlx90393/__init__.py index fc92f02120..800d120558 100644 --- a/esphome/components/mlx90393/__init__.py +++ b/esphome/components/mlx90393/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@functionpointer"] +DOMAIN = "mlx90393" 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/mlx90393/sensor_mlx90393.cpp b/esphome/components/mlx90393/sensor_mlx90393.cpp index 2288e8ff84..e47b7d8c30 100644 --- a/esphome/components/mlx90393/sensor_mlx90393.cpp +++ b/esphome/components/mlx90393/sensor_mlx90393.cpp @@ -5,7 +5,9 @@ namespace esphome::mlx90393 { -static const char *const TAG = "mlx90393"; +ESPHOME_LOG_TAG(TAG, "mlx90393"); + +static constexpr uint32_t VERIFY_SETTINGS_TIMEOUT_ID = 0; const LogString *settings_to_string(MLX90393Setting setting) { switch (setting) { @@ -114,7 +116,7 @@ void MLX90393Cls::setup() { } // start verify settings process - this->set_timeout("verify settings", 3000, [this]() { this->verify_settings_timeout_(MLX90393_GAIN_SEL); }); + this->set_timeout(VERIFY_SETTINGS_TIMEOUT_ID, 3000, [this]() { this->verify_settings_timeout_(MLX90393_GAIN_SEL); }); } void MLX90393Cls::dump_config() { @@ -268,7 +270,8 @@ void MLX90393Cls::verify_settings_timeout_(MLX90393Setting stage) { next_stage = static_cast(0); } - this->set_timeout("verify settings", 3000, [this, next_stage]() { this->verify_settings_timeout_(next_stage); }); + this->set_timeout(VERIFY_SETTINGS_TIMEOUT_ID, 3000, + [this, next_stage]() { this->verify_settings_timeout_(next_stage); }); } } // namespace esphome::mlx90393 diff --git a/esphome/components/mlx90614/__init__.py b/esphome/components/mlx90614/__init__.py index e69de29bb2..e1db3524a4 100644 --- a/esphome/components/mlx90614/__init__.py +++ b/esphome/components/mlx90614/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mlx90614" diff --git a/esphome/components/mlx90614/mlx90614.cpp b/esphome/components/mlx90614/mlx90614.cpp index 508b3743d1..a67d770cc3 100644 --- a/esphome/components/mlx90614/mlx90614.cpp +++ b/esphome/components/mlx90614/mlx90614.cpp @@ -24,7 +24,7 @@ static const uint8_t MLX90614_ID2 = 0x3D; static const uint8_t MLX90614_ID3 = 0x3E; static const uint8_t MLX90614_ID4 = 0x3F; -static const char *const TAG = "mlx90614"; +ESPHOME_LOG_TAG(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; 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/__init__.py b/esphome/components/mmc5603/__init__.py index cc88f26231..b7008ff157 100644 --- a/esphome/components/mmc5603/__init__.py +++ b/esphome/components/mmc5603/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@benhoff"] +DOMAIN = "mmc5603" diff --git a/esphome/components/mmc5603/mmc5603.cpp b/esphome/components/mmc5603/mmc5603.cpp index 15e715e675..b7d16b30ed 100644 --- a/esphome/components/mmc5603/mmc5603.cpp +++ b/esphome/components/mmc5603/mmc5603.cpp @@ -5,7 +5,7 @@ namespace esphome::mmc5603 { -static const char *const TAG = "mmc5603"; +ESPHOME_LOG_TAG(TAG, "mmc5603"); static const uint8_t MMC5603_ADDRESS = 0x30; static const uint8_t MMC56X3_PRODUCT_ID = 0x39; 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/__init__.py b/esphome/components/mmc5983/__init__.py index c8db8c4300..5e5287db5f 100644 --- a/esphome/components/mmc5983/__init__.py +++ b/esphome/components/mmc5983/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@agoode"] +DOMAIN = "mmc5983" diff --git a/esphome/components/mmc5983/mmc5983.cpp b/esphome/components/mmc5983/mmc5983.cpp index a99df36a70..f7840aafbe 100644 --- a/esphome/components/mmc5983/mmc5983.cpp +++ b/esphome/components/mmc5983/mmc5983.cpp @@ -6,7 +6,7 @@ namespace esphome::mmc5983 { -static const char *const TAG = "mmc5983"; +ESPHOME_LOG_TAG(TAG, "mmc5983"); namespace { constexpr uint8_t IC0_ADDR = 0x09; 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 fe93758726..4e4d7fe160 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -7,6 +7,7 @@ from typing import Any, Literal, NamedTuple from esphome import pins import esphome.codegen as cg from esphome.components import uart +from esphome.components.const import CONF_ROLE import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_FLOW_CONTROL_PIN, CONF_ID from esphome.cpp_generator import MockObj @@ -17,6 +18,7 @@ from esphome.types import ConfigType, TemplateArgsType _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["uart"] +DOMAIN = "modbus" # Loading the hub makes the modbus_client.* actions available (they are registry entries only; no code is # generated unless a config uses one). AUTO_LOAD = ["modbus_client"] @@ -45,13 +47,18 @@ MULTI_CONF = True CONF_ALLOW_BROADCAST_READ = "allow_broadcast_read" CONF_EXPECT_BROADCAST_WRITE_RESPONSE = "expect_broadcast_write_response" -CONF_ROLE = "role" CONF_MODBUS_ID = "modbus_id" CONF_SEND_WAIT_TIME = "send_wait_time" CONF_TURNAROUND_TIME = "turnaround_time" MODBUS_ROLES = ["client", "server"] +# The client hub takes these times as 16-bit milliseconds; a Modbus timeout is far shorter anyway. +_HUB_TIME_PERIOD = cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(milliseconds=65535)), +) + # The write (mutating) function codes, matching modbus::helpers::is_function_code_write(). 0x17 # (read/write multiple) is included: it mutates, so the hub treats it as a write despite its read half. @@ -273,12 +280,8 @@ CONFIG_SCHEMA = cv.typed_schema( { cv.GenerateID(): cv.declare_id(ModbusClient), cv.Optional(CONF_FLOW_CONTROL_PIN): pins.gpio_output_pin_schema, - cv.Optional( - CONF_SEND_WAIT_TIME, default="2000ms" - ): cv.positive_time_period_milliseconds, - cv.Optional( - CONF_TURNAROUND_TIME, default="600ms" - ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_SEND_WAIT_TIME, default="2000ms"): _HUB_TIME_PERIOD, + cv.Optional(CONF_TURNAROUND_TIME, default="600ms"): _HUB_TIME_PERIOD, } ) .extend(cv.COMPONENT_SCHEMA) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 0cf57ef7aa..a79a7c3bd8 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -9,7 +9,9 @@ namespace esphome::modbus { -static const char *const TAG = "modbus"; +ESPHOME_LOG_TAG(TAG, "modbus"); + +static constexpr uint32_t DEFERRED_SEND_TIMEOUT_ID = 0; static constexpr size_t MODBUS_MAX_LOG_BYTES = 64; @@ -205,12 +207,12 @@ 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"); + this->cancel_timeout(DEFERRED_SEND_TIMEOUT_ID); 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; @@ -948,7 +950,7 @@ bool ModbusDeviceCommand::sent() { // on_sent() is not a terminal, so nothing is consumed. if (this->device == nullptr) return false; - this->device->on_sent(this->frame.pdu()); + this->device->on_sent(this->frame.address(), this->frame.pdu()); return true; } @@ -956,7 +958,7 @@ bool ModbusDeviceCommand::notify_retired() { if (!this->decrement_pending()) return false; // nothing owed - stop the sweep draining this entry if (this->device != nullptr) - this->device->on_not_sent(this->frame.pdu()); + this->device->on_not_sent(this->frame.address(), this->frame.pdu()); return true; // consumed one debt (delivered, or silent when device-less) - keep draining to zero } @@ -967,7 +969,7 @@ bool ModbusDeviceCommand::response(std::span response_pdu) { this->decrement_pending(); if (this->device == nullptr) return false; - this->device->on_response(this->frame.pdu(), response_pdu); + this->device->on_response(this->frame.address(), this->frame.pdu(), response_pdu); return true; } @@ -977,7 +979,7 @@ bool ModbusDeviceCommand::error(ExceptionCode exception_code) { this->decrement_pending(); if (this->device == nullptr) return false; - this->device->on_error(this->frame.pdu(), exception_code); + this->device->on_error(this->frame.address(), this->frame.pdu(), exception_code); return true; } @@ -1002,7 +1004,7 @@ bool ModbusDeviceCommand::timed_out() { return false; // resolved, no one to tell // A cleared frame that timed out still honors a retry: the clear is address-scoped (any device may // call it) while the retry is the owning device's call via on_no_response - the bus obeys the owner. - if (this->device->on_no_response(this->frame.pdu())) + if (this->device->on_no_response(this->frame.address(), this->frame.pdu())) this->increment_pending(); // granted retry = re-request (capped) return true; } @@ -1216,7 +1218,7 @@ void ModbusServerHub::send_raw_(const uint8_t *payload, uint16_t len) { std::memcpy(this->deferred_payload_.data(), payload, len); this->deferred_payload_len_ = len; // set_timeout() takes milliseconds; round the microsecond delay up so we never fire early. - this->set_timeout("deferred_send", (this->tx_delay_remaining() + US_PER_MS - 1) / US_PER_MS, [this]() { + this->set_timeout(DEFERRED_SEND_TIMEOUT_ID, (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; diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 298cd9f527..011813a20d 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -437,6 +437,21 @@ class ModbusClientDevice { return this->on_modbus_no_response(); #pragma GCC diagnostic pop } + /// Overloads of the hooks above with the device address of the request first, taken from the request frame, so it + /// stays correct after set_address(). The hub calls these; the defaults forward to the hooks above. + /// Override one form per hook: overriding an address form drops its forward, so the other form is no longer called. + virtual void on_response(uint8_t address, std::span request_pdu, + std::span response_pdu) { + this->on_response(request_pdu, response_pdu); + } + virtual void on_error(uint8_t address, std::span request_pdu, ExceptionCode exception_code) { + this->on_error(request_pdu, exception_code); + } + virtual void on_not_sent(uint8_t address, std::span request_pdu) { this->on_not_sent(request_pdu); } + virtual void on_sent(uint8_t address, std::span request_pdu) { this->on_sent(request_pdu); } + virtual bool on_no_response(uint8_t address, std::span request_pdu) { + return this->on_no_response(request_pdu); + } // Remove before 2027.2.0 ESPDEPRECATED("Override on_not_sent() instead. Removed in 2027.2.0", "2026.8.0") virtual void on_modbus_not_sent() {} diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index d80e6c86ad..19edc98938 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -5,7 +5,7 @@ namespace esphome::modbus::helpers { -static const char *const TAG = "modbus_helpers"; +ESPHOME_LOG_TAG(TAG, "modbus_helpers"); // A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum, // and the range [start_address, start_address + quantity) stays inside the 16-bit address space. diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 66ddcd7722..67c3cd75bd 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@exciton"] DEPENDENCIES = ["modbus"] +DOMAIN = "modbus_client" MULTI_CONF = True # The modbus hub auto-loads this component to make the actions available. Without this, that auto-load # would try to create a device with no address. @@ -112,20 +113,18 @@ def _packed_bit_bytes(bits: int) -> int: return (bits + 7) // 8 -def _synchronous_handler(value: ConfigType) -> ConfigType: - """Reject deferring actions in a handler: its PDU spans point into hub buffers that are reused - once the handler returns, and DelayAction and friends capture the trigger args for later replay.""" - if automation.has_non_synchronous_actions(value): - raise cv.Invalid( - "Deferring actions (delay, wait_until, script.wait, ...) are not allowed in modbus_client " - "handlers: the request/response data is only valid while the handler runs. Copy what you " - "need into globals first, then defer in a separate script or automation." - ) - return value +# Handler PDU spans point into hub buffers that are reused once the handler returns. +_HANDLER_CONTEXT = "modbus_client handlers" +_HANDLER_DATA = "the request/response data" +_synchronous_handler = automation.validate_no_deferring_actions( + _HANDLER_CONTEXT, _HANDLER_DATA +) def _handler_schema() -> cv.All: - return cv.All(automation.validate_automation(single=True), _synchronous_handler) + return automation.validate_synchronous_automation( + _HANDLER_CONTEXT, _HANDLER_DATA, single=True + ) # Each action is its own hub device: the modbus hub routes the reply straight back to the action that diff --git a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp index b0c927cf84..679b8dc714 100644 --- a/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp +++ b/esphome/components/modbus_controller/binary_sensor/modbus_binarysensor.cpp @@ -3,7 +3,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.binary_sensor"); void ModbusBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Modbus Controller Binary Sensor", this); } diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index b8d06d3d5a..6c90bfa464 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -7,7 +7,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller"; +ESPHOME_LOG_TAG(TAG, "modbus_controller"); void ModbusController::setup() { this->create_polling_commands_(); } diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 223aa12bec..f33fc36b0a 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus.number"; +ESPHOME_LOG_TAG(TAG, "modbus.number"); // Maximum uint16_t registers to log in verbose hex output static constexpr size_t MODBUS_NUMBER_MAX_LOG_REGISTERS = 32; diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index ad29015d32..9fb40f9005 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -6,7 +6,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.output"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.output"); // Maximum bytes to log in verbose hex output static constexpr size_t MODBUS_OUTPUT_MAX_LOG_BYTES = 64; diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index a2f15d54f6..793aa8e9ea 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -3,7 +3,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.select"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.select"); void ModbusSelect::dump_config() { LOG_SELECT(TAG, "Modbus Controller Select", this); } diff --git a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp index 2035f2220a..98037c5237 100644 --- a/esphome/components/modbus_controller/sensor/modbus_sensor.cpp +++ b/esphome/components/modbus_controller/sensor/modbus_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.sensor"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.sensor"); void ModbusSensor::dump_config() { LOG_SENSOR(TAG, "Modbus Controller Sensor", this); } diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 855a7b28c3..fe2b276231 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -7,7 +7,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.switch"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.switch"); // Maximum bytes to log in verbose hex output static constexpr size_t MODBUS_SWITCH_MAX_LOG_BYTES = 64; diff --git a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp index 31b3fb3e55..74efd41e0f 100644 --- a/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp +++ b/esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp @@ -4,7 +4,7 @@ namespace esphome::modbus_controller { -static const char *const TAG = "modbus_controller.text_sensor"; +ESPHOME_LOG_TAG(TAG, "modbus_controller.text_sensor"); void ModbusTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Modbus Controller Text Sensor", this); } diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 0de40a4cc1..bff23e364e 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -23,6 +23,7 @@ from .const import ( ) CODEOWNERS = ["@exciton"] +DOMAIN = "modbus_server" AUTO_LOAD = ["modbus"] diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index 65bf4ef2f4..763bbf872d 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -6,7 +6,7 @@ namespace esphome::modbus_server { using modbus::ExceptionCode; using modbus::helpers::registers_to_number; -static const char *const TAG = "modbus_server"; +ESPHOME_LOG_TAG(TAG, "modbus_server"); // The widest Modbus value type (QWORD) spans four registers. static constexpr uint8_t MAX_REGISTERS_PER_VALUE = 4; diff --git a/esphome/components/monochromatic/__init__.py b/esphome/components/monochromatic/__init__.py index e69de29bb2..4f572e8557 100644 --- a/esphome/components/monochromatic/__init__.py +++ b/esphome/components/monochromatic/__init__.py @@ -0,0 +1 @@ +DOMAIN = "monochromatic" diff --git a/esphome/components/mopeka_ble/__init__.py b/esphome/components/mopeka_ble/__init__.py index ec7deb3067..968447dadf 100644 --- a/esphome/components/mopeka_ble/__init__.py +++ b/esphome/components/mopeka_ble/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@spbrogan", "@Fabian-Schmidt"] AUTO_LOAD = ["ble_device_base"] +DOMAIN = "mopeka_ble" CONF_SHOW_SENSORS_WITHOUT_SYNC = "show_sensors_without_sync" diff --git a/esphome/components/mopeka_ble/mopeka_ble.cpp b/esphome/components/mopeka_ble/mopeka_ble.cpp index 0bef1eb6d4..24e1667a4b 100644 --- a/esphome/components/mopeka_ble/mopeka_ble.cpp +++ b/esphome/components/mopeka_ble/mopeka_ble.cpp @@ -4,7 +4,7 @@ namespace esphome::mopeka_ble { -static const char *const TAG = "mopeka_ble"; +ESPHOME_LOG_TAG(TAG, "mopeka_ble"); // Mopeka Std (CC2540) sensor details static const uint16_t SERVICE_UUID_CC2540 = 0xADA0; diff --git a/esphome/components/mopeka_pro_check/__init__.py b/esphome/components/mopeka_pro_check/__init__.py index c57f60f521..86bf451227 100644 --- a/esphome/components/mopeka_pro_check/__init__.py +++ b/esphome/components/mopeka_pro_check/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@spbrogan"] +DOMAIN = "mopeka_pro_check" diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp index fe3178d3aa..ba93c70d15 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -3,7 +3,7 @@ namespace esphome::mopeka_pro_check { -static const char *const TAG = "mopeka_pro_check"; +ESPHOME_LOG_TAG(TAG, "mopeka_pro_check"); static const uint8_t MANUFACTURER_DATA_LENGTH = 10; static const uint16_t MANUFACTURER_ID = 0x0059; static const double MOPEKA_LPG_COEF[] = {0.573045, -0.002822, -0.00000535}; // Magic numbers provided by Mopeka 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/__init__.py b/esphome/components/mopeka_std_check/__init__.py index 88e344464f..be8f3d329b 100644 --- a/esphome/components/mopeka_std_check/__init__.py +++ b/esphome/components/mopeka_std_check/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Fabian-Schmidt"] +DOMAIN = "mopeka_std_check" diff --git a/esphome/components/mopeka_std_check/mopeka_std_check.cpp b/esphome/components/mopeka_std_check/mopeka_std_check.cpp index d70306c97d..f63f2bc190 100644 --- a/esphome/components/mopeka_std_check/mopeka_std_check.cpp +++ b/esphome/components/mopeka_std_check/mopeka_std_check.cpp @@ -5,7 +5,7 @@ namespace esphome::mopeka_std_check { -static const char *const TAG = "mopeka_std_check"; +ESPHOME_LOG_TAG(TAG, "mopeka_std_check"); static const uint16_t SERVICE_UUID = 0xADA0; static const uint8_t MANUFACTURER_DATA_LENGTH = 23; static const uint16_t MANUFACTURER_ID = 0x000D; 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..1de535a0ff --- /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 { + +ESPHOME_LOG_TAG(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..9ad2668fd4 --- /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 { + +ESPHOME_LOG_TAG(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..3171bef4ec 100644 --- a/esphome/components/motion/motion_component.cpp +++ b/esphome/components/motion/motion_component.cpp @@ -3,7 +3,7 @@ namespace esphome::motion { -static const char *const TAG = "motion"; +ESPHOME_LOG_TAG(TAG, "motion"); static void log_matrix(const float m[9]) { ESP_LOGCONFIG(TAG, " Calibration matrix:"); @@ -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/__init__.py b/esphome/components/mpl3115a2/__init__.py index e69de29bb2..08956d32f6 100644 --- a/esphome/components/mpl3115a2/__init__.py +++ b/esphome/components/mpl3115a2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mpl3115a2" diff --git a/esphome/components/mpl3115a2/mpl3115a2.cpp b/esphome/components/mpl3115a2/mpl3115a2.cpp index 238e37aff0..db89271625 100644 --- a/esphome/components/mpl3115a2/mpl3115a2.cpp +++ b/esphome/components/mpl3115a2/mpl3115a2.cpp @@ -5,7 +5,7 @@ namespace esphome::mpl3115a2 { -static const char *const TAG = "mpl3115a2"; +ESPHOME_LOG_TAG(TAG, "mpl3115a2"); void MPL3115A2Component::setup() { uint8_t whoami = 0xFF; 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/mpr121/__init__.py b/esphome/components/mpr121/__init__.py index da56b4ff4b..1764ff9c05 100644 --- a/esphome/components/mpr121/__init__.py +++ b/esphome/components/mpr121/__init__.py @@ -25,6 +25,7 @@ CONF_MPR121 = "mpr121" CONF_MPR121_ID = "mpr121_id" DEPENDENCIES = ["i2c"] +DOMAIN = "mpr121" mpr121_ns = cg.esphome_ns.namespace("mpr121") MPR121Component = mpr121_ns.class_("MPR121Component", cg.Component, i2c.I2CDevice) diff --git a/esphome/components/mpr121/mpr121.cpp b/esphome/components/mpr121/mpr121.cpp index 6b183233d1..26cdcae613 100644 --- a/esphome/components/mpr121/mpr121.cpp +++ b/esphome/components/mpr121/mpr121.cpp @@ -7,7 +7,7 @@ namespace esphome::mpr121 { -static const char *const TAG = "mpr121"; +ESPHOME_LOG_TAG(TAG, "mpr121"); void MPR121Component::setup() { this->disable_loop(); diff --git a/esphome/components/mpu6050/__init__.py b/esphome/components/mpu6050/__init__.py index e69de29bb2..ee02560478 100644 --- a/esphome/components/mpu6050/__init__.py +++ b/esphome/components/mpu6050/__init__.py @@ -0,0 +1 @@ +DOMAIN = "mpu6050" diff --git a/esphome/components/mpu6050/mpu6050.cpp b/esphome/components/mpu6050/mpu6050.cpp index 8784e8caf8..cf393fd4d1 100644 --- a/esphome/components/mpu6050/mpu6050.cpp +++ b/esphome/components/mpu6050/mpu6050.cpp @@ -3,7 +3,7 @@ namespace esphome::mpu6050 { -static const char *const TAG = "mpu6050"; +ESPHOME_LOG_TAG(TAG, "mpu6050"); const uint8_t MPU6050_REGISTER_WHO_AM_I = 0x75; const uint8_t MPU6050_REGISTER_POWER_MANAGEMENT_1 = 0x6B; 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/__init__.py b/esphome/components/mpu6886/__init__.py index 933f71ccd7..af9cdd2f27 100644 --- a/esphome/components/mpu6886/__init__.py +++ b/esphome/components/mpu6886/__init__.py @@ -1 +1,3 @@ """Support for MPC-6886.""" + +DOMAIN = "mpu6886" diff --git a/esphome/components/mpu6886/mpu6886.cpp b/esphome/components/mpu6886/mpu6886.cpp index b8cbd4635a..514ff0dd0e 100644 --- a/esphome/components/mpu6886/mpu6886.cpp +++ b/esphome/components/mpu6886/mpu6886.cpp @@ -3,7 +3,7 @@ namespace esphome::mpu6886 { -static const char *const TAG = "mpu6886"; +ESPHOME_LOG_TAG(TAG, "mpu6886"); const uint8_t MPU6886_REGISTER_WHO_AM_I = 0x75; const uint8_t MPU6886_REGISTER_POWER_MANAGEMENT_1 = 0x6B; 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 f4be684318..826d4e32ef 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -3,9 +3,9 @@ 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, @@ -68,6 +68,7 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import ConfigType DEPENDENCIES = ["network"] +DOMAIN = "mqtt" def AUTO_LOAD(): @@ -117,10 +118,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 ) @@ -226,6 +224,17 @@ def _consume_mqtt_sockets(config: ConfigType) -> ConfigType: return config +# The parsed JSON document is freed once the automation returns +JSON_MESSAGE_AUTOMATION_SCHEMA = automation.validate_synchronous_automation( + "on_json_message", + "the parsed JSON document", + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(MQTTJsonMessageTrigger), + cv.Required(CONF_TOPIC): cv.subscribe_topic, + cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, + }, +) + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -246,8 +255,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 @@ -306,15 +315,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_PAYLOAD): cv.string_strict, } ), - cv.Optional(CONF_ON_JSON_MESSAGE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - MQTTJsonMessageTrigger - ), - cv.Required(CONF_TOPIC): cv.subscribe_topic, - cv.Optional(CONF_QOS, default=0): cv.mqtt_qos, - } - ), + cv.Optional(CONF_ON_JSON_MESSAGE): JSON_MESSAGE_AUTOMATION_SCHEMA, cv.Optional(CONF_PUBLISH_NAN_AS_NONE, default=False): cv.boolean, cv.Optional(CONF_WAIT_FOR_CONNECTION, default=False): cv.boolean, } @@ -362,8 +363,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) @@ -455,10 +456,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 @@ -503,22 +500,26 @@ MQTT_PUBLISH_ACTION_SCHEMA = cv.Schema( ) -@automation.register_action( - "mqtt.publish", MQTTPublishAction, MQTT_PUBLISH_ACTION_SCHEMA, synchronous=True -) -async def mqtt_publish_action_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_TOPIC], args, cg.std_string) - cg.add(var.set_topic(template_)) +# A bare literal is ambiguous between the std::string and (const char *, size_t) publish +# overloads, so constants and inlined `return "...";` lambdas are both spelled as std::string. +def _std_string(config: ConfigType, value: str) -> str: + rendered = automation.flash_string(config, value) + return rendered if CORE.is_esp8266 else f"std::string({rendered})" - template_ = await cg.templatable(config[CONF_PAYLOAD], args, cg.std_string) - cg.add(var.set_payload(template_)) - template_ = await cg.templatable(config[CONF_QOS], args, cg.uint8) - cg.add(var.set_qos(template_)) - template_ = await cg.templatable(config[CONF_RETAIN], args, cg.bool_) - cg.add(var.set_retain(template_)) - return var + +automation.register_apply_action( + "mqtt.publish", + MQTT_PUBLISH_ACTION_SCHEMA, + automation.ApplyCall( + "publish({}, {}, {}, {})", + ( + (CONF_TOPIC, "std::string", _std_string), + (CONF_PAYLOAD, "std::string", _std_string), + (CONF_QOS, cg.uint8), + (CONF_RETAIN, cg.bool_), + ), + ), +) MQTT_PUBLISH_JSON_ACTION_SCHEMA = cv.Schema( @@ -608,34 +609,16 @@ automation.register_apply_condition( ) -@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/custom_mqtt_device.cpp b/esphome/components/mqtt/custom_mqtt_device.cpp index 64521f5cf3..42171fcfb1 100644 --- a/esphome/components/mqtt/custom_mqtt_device.cpp +++ b/esphome/components/mqtt/custom_mqtt_device.cpp @@ -6,7 +6,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.custom"; +ESPHOME_LOG_TAG(TAG, "mqtt.custom"); bool CustomMQTTDevice::publish(const std::string &topic, const std::string &payload, uint8_t qos, bool retain) { return global_mqtt_client->publish(topic, payload, qos, retain); diff --git a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp index f059360e23..225f839633 100644 --- a/esphome/components/mqtt/mqtt_alarm_control_panel.cpp +++ b/esphome/components/mqtt/mqtt_alarm_control_panel.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.alarm_control_panel"; +ESPHOME_LOG_TAG(TAG, "mqtt.alarm_control_panel"); using namespace esphome::alarm_control_panel; diff --git a/esphome/components/mqtt/mqtt_backend_esp32.cpp b/esphome/components/mqtt/mqtt_backend_esp32.cpp index 09eb5f97dc..3b49ca33cd 100644 --- a/esphome/components/mqtt/mqtt_backend_esp32.cpp +++ b/esphome/components/mqtt/mqtt_backend_esp32.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt"; +ESPHOME_LOG_TAG(TAG, "mqtt"); bool MQTTBackendESP32::initialize_() { mqtt_cfg_.broker.address.hostname = this->host_.c_str(); @@ -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_binary_sensor.cpp b/esphome/components/mqtt/mqtt_binary_sensor.cpp index ebb29db44f..7a75cca6e8 100644 --- a/esphome/components/mqtt/mqtt_binary_sensor.cpp +++ b/esphome/components/mqtt/mqtt_binary_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt.binary_sensor"); MQTT_COMPONENT_TYPE(MQTTBinarySensorComponent, "binary_sensor") const EntityBase *MQTTBinarySensorComponent::get_entity() const { return this->binary_sensor_; } diff --git a/esphome/components/mqtt/mqtt_button.cpp b/esphome/components/mqtt/mqtt_button.cpp index 7e0ae7d06e..a04121e2a1 100644 --- a/esphome/components/mqtt/mqtt_button.cpp +++ b/esphome/components/mqtt/mqtt_button.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.button"; +ESPHOME_LOG_TAG(TAG, "mqtt.button"); using namespace esphome::button; @@ -20,7 +20,7 @@ void MQTTButtonComponent::setup() { this->button_->press(); } else { ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); } diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 2ecab47904..6e5fad3efc 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -26,7 +26,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt"; +ESPHOME_LOG_TAG(TAG, "mqtt"); // Maximum number of MQTT component resends per loop iteration. // Limits work to avoid triggering the task watchdog on reconnect. @@ -439,7 +439,7 @@ bool MQTTClientComponent::subscribe_(const char *topic, uint8_t qos) { } else { delay(5); ESP_LOGV(TAG, "Subscribe failed for topic='%s'. Will retry", topic); - this->status_momentary_warning("subscribe", 1000); + this->status_momentary_warning(1000); } return ret != 0; } @@ -499,7 +499,7 @@ void MQTTClientComponent::unsubscribe(const std::string &topic) { } else { delay(5); ESP_LOGV(TAG, "Unsubscribe failed for topic='%s'.", topic.c_str()); - this->status_momentary_warning("unsubscribe", 1000); + this->status_momentary_warning(1000); } auto it = subscriptions_.begin(); @@ -553,7 +553,7 @@ bool MQTTClientComponent::publish(const char *topic, const char *payload, size_t ESP_LOGVV(TAG, "Publish payload (len=%u): '%.*s'", payload_length, static_cast(payload_length), payload); } else { ESP_LOGV(TAG, "Publish failed for topic='%s' (len=%u). Will retry", topic, payload_length); - this->status_momentary_warning("publish", 1000); + this->status_momentary_warning(1000); } } return ret != 0; diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 2fd9a5a550..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,26 +400,6 @@ template class MQTTPublishJsonAction final : public Action 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_climate.cpp b/esphome/components/mqtt/mqtt_climate.cpp index 0e6a374f9b..83efc3da9c 100644 --- a/esphome/components/mqtt/mqtt_climate.cpp +++ b/esphome/components/mqtt/mqtt_climate.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.climate"; +ESPHOME_LOG_TAG(TAG, "mqtt.climate"); using namespace esphome::climate; diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 59a5d02d97..adb5a95828 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -12,7 +12,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.component"; +ESPHOME_LOG_TAG(TAG, "mqtt.component"); // Entity category MQTT strings indexed by EntityCategory enum: NONE(0) is skipped, CONFIG(1), DIAGNOSTIC(2) PROGMEM_STRING_TABLE(EntityCategoryMqttStrings, "", "config", "diagnostic"); diff --git a/esphome/components/mqtt/mqtt_component.h b/esphome/components/mqtt/mqtt_component.h index b4ae624404..141b5f12b3 100644 --- a/esphome/components/mqtt/mqtt_component.h +++ b/esphome/components/mqtt/mqtt_component.h @@ -273,6 +273,8 @@ class MQTTComponent : public Component { void subscribe_json(const std::string &topic, const mqtt_json_callback_t &callback, uint8_t qos = 0); protected: + static constexpr uint32_t SEND_DEFER_ID = 0; + /// Helper method to get the discovery topic for this component into a buffer. StringRef get_discovery_topic_to_(std::span buf, const MQTTDiscoveryInfo &discovery_info) const; diff --git a/esphome/components/mqtt/mqtt_cover.cpp b/esphome/components/mqtt/mqtt_cover.cpp index ddb4b2d69d..a7da9a1b22 100644 --- a/esphome/components/mqtt/mqtt_cover.cpp +++ b/esphome/components/mqtt/mqtt_cover.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.cover"; +ESPHOME_LOG_TAG(TAG, "mqtt.cover"); using namespace esphome::cover; diff --git a/esphome/components/mqtt/mqtt_date.cpp b/esphome/components/mqtt/mqtt_date.cpp index c422bb3058..b5f1cf8fb8 100644 --- a/esphome/components/mqtt/mqtt_date.cpp +++ b/esphome/components/mqtt/mqtt_date.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.datetime"; +ESPHOME_LOG_TAG(TAG, "mqtt.datetime"); using namespace esphome::datetime; diff --git a/esphome/components/mqtt/mqtt_datetime.cpp b/esphome/components/mqtt/mqtt_datetime.cpp index 1492abd011..8275580938 100644 --- a/esphome/components/mqtt/mqtt_datetime.cpp +++ b/esphome/components/mqtt/mqtt_datetime.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.datetime.datetime"; +ESPHOME_LOG_TAG(TAG, "mqtt.datetime.datetime"); using namespace esphome::datetime; diff --git a/esphome/components/mqtt/mqtt_event.cpp b/esphome/components/mqtt/mqtt_event.cpp index 93ff6971b3..4266722ca8 100644 --- a/esphome/components/mqtt/mqtt_event.cpp +++ b/esphome/components/mqtt/mqtt_event.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.event"; +ESPHOME_LOG_TAG(TAG, "mqtt.event"); using namespace esphome::event; diff --git a/esphome/components/mqtt/mqtt_fan.cpp b/esphome/components/mqtt/mqtt_fan.cpp index 3a8658a55f..7aa2f8e9d5 100644 --- a/esphome/components/mqtt/mqtt_fan.cpp +++ b/esphome/components/mqtt/mqtt_fan.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.fan"; +ESPHOME_LOG_TAG(TAG, "mqtt.fan"); using namespace esphome::fan; @@ -46,7 +46,7 @@ void MQTTFanComponent::setup() { case PARSE_NONE: default: ESP_LOGW(TAG, "Unknown state payload %s", payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); break; } }); @@ -71,7 +71,7 @@ void MQTTFanComponent::setup() { break; case PARSE_NONE: ESP_LOGW(TAG, "Unknown direction Payload %s", payload.c_str()); - this->status_momentary_warning("direction", 5000); + this->status_momentary_warning(5000); break; } }); @@ -95,7 +95,7 @@ void MQTTFanComponent::setup() { break; case PARSE_NONE: ESP_LOGW(TAG, "Unknown Oscillation Payload %s", payload.c_str()); - this->status_momentary_warning("oscillation", 5000); + this->status_momentary_warning(5000); break; } }); @@ -112,16 +112,16 @@ void MQTTFanComponent::setup() { this->state_->make_call().set_speed(speed_level).perform(); } else { ESP_LOGW(TAG, "Invalid speed level %d", speed_level); - this->status_momentary_warning("speed", 5000); + this->status_momentary_warning(5000); } } else { ESP_LOGW(TAG, "Invalid speed level %s (int expected)", payload.c_str()); - this->status_momentary_warning("speed", 5000); + this->status_momentary_warning(5000); } }); } - this->state_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->state_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTFanComponent::dump_config() { diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index a8b52a3839..e1affae8a2 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -10,7 +10,7 @@ #include "esphome/components/light/light_json_schema.h" namespace esphome::mqtt { -static const char *const TAG = "mqtt.light"; +ESPHOME_LOG_TAG(TAG, "mqtt.light"); using namespace esphome::light; @@ -28,7 +28,7 @@ void MQTTJSONLightComponent::setup() { } void MQTTJSONLightComponent::on_light_remote_values_update() { - this->defer("send", [this]() { this->publish_state_(); }); + this->defer(SEND_DEFER_ID, [this]() { this->publish_state_(); }); } MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {} diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 369c1c6668..8e9a628985 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.lock"; +ESPHOME_LOG_TAG(TAG, "mqtt.lock"); using namespace esphome::lock; @@ -25,11 +25,11 @@ void MQTTLockComponent::setup() { this->lock_->open(); } else { ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); this->lock_->add_on_state_callback( - [this](LockState /*state*/) { this->defer("send", [this]() { this->publish_state(); }); }); + [this](LockState /*state*/) { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } void MQTTLockComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Lock '%s': ", this->lock_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_number.cpp b/esphome/components/mqtt/mqtt_number.cpp index b0bac8b3d7..3b2bebf291 100644 --- a/esphome/components/mqtt/mqtt_number.cpp +++ b/esphome/components/mqtt/mqtt_number.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.number"; +ESPHOME_LOG_TAG(TAG, "mqtt.number"); using namespace esphome::number; diff --git a/esphome/components/mqtt/mqtt_select.cpp b/esphome/components/mqtt/mqtt_select.cpp index 25fd813496..42c0ae002e 100644 --- a/esphome/components/mqtt/mqtt_select.cpp +++ b/esphome/components/mqtt/mqtt_select.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.select"; +ESPHOME_LOG_TAG(TAG, "mqtt.select"); using namespace esphome::select; diff --git a/esphome/components/mqtt/mqtt_sensor.cpp b/esphome/components/mqtt/mqtt_sensor.cpp index a601a9bcc8..bdaaa47e8f 100644 --- a/esphome/components/mqtt/mqtt_sensor.cpp +++ b/esphome/components/mqtt/mqtt_sensor.cpp @@ -14,7 +14,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt.sensor"); using namespace esphome::sensor; diff --git a/esphome/components/mqtt/mqtt_switch.cpp b/esphome/components/mqtt/mqtt_switch.cpp index 70cd03a4eb..44ce249772 100644 --- a/esphome/components/mqtt/mqtt_switch.cpp +++ b/esphome/components/mqtt/mqtt_switch.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.switch"; +ESPHOME_LOG_TAG(TAG, "mqtt.switch"); using namespace esphome::switch_; @@ -29,12 +29,12 @@ void MQTTSwitchComponent::setup() { case PARSE_NONE: default: ESP_LOGW(TAG, "'%s': Received unknown status payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); break; } }); this->switch_->add_on_state_callback( - [this](bool enabled) { this->defer("send", [this, enabled]() { this->publish_state(enabled); }); }); + [this](bool enabled) { this->defer(SEND_DEFER_ID, [this, enabled]() { this->publish_state(enabled); }); }); } void MQTTSwitchComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT Switch '%s': ", this->switch_->get_name().c_str()); diff --git a/esphome/components/mqtt/mqtt_text.cpp b/esphome/components/mqtt/mqtt_text.cpp index 200e420c9c..dd43a8e2cb 100644 --- a/esphome/components/mqtt/mqtt_text.cpp +++ b/esphome/components/mqtt/mqtt_text.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.text"; +ESPHOME_LOG_TAG(TAG, "mqtt.text"); using namespace esphome::text; diff --git a/esphome/components/mqtt/mqtt_text_sensor.cpp b/esphome/components/mqtt/mqtt_text_sensor.cpp index 3acd71b50d..d105aaac99 100644 --- a/esphome/components/mqtt/mqtt_text_sensor.cpp +++ b/esphome/components/mqtt/mqtt_text_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.text_sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt.text_sensor"); using namespace esphome::text_sensor; diff --git a/esphome/components/mqtt/mqtt_time.cpp b/esphome/components/mqtt/mqtt_time.cpp index be391ce88c..889ee2bf34 100644 --- a/esphome/components/mqtt/mqtt_time.cpp +++ b/esphome/components/mqtt/mqtt_time.cpp @@ -10,7 +10,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.datetime.time"; +ESPHOME_LOG_TAG(TAG, "mqtt.datetime.time"); using namespace esphome::datetime; diff --git a/esphome/components/mqtt/mqtt_update.cpp b/esphome/components/mqtt/mqtt_update.cpp index c01fb9e52e..69b2f64771 100644 --- a/esphome/components/mqtt/mqtt_update.cpp +++ b/esphome/components/mqtt/mqtt_update.cpp @@ -8,7 +8,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.update"; +ESPHOME_LOG_TAG(TAG, "mqtt.update"); using namespace esphome::update; @@ -20,11 +20,11 @@ void MQTTUpdateComponent::setup() { this->update_->perform(); } else { ESP_LOGW(TAG, "'%s': Received unknown update payload: %s", this->friendly_name_().c_str(), payload.c_str()); - this->status_momentary_warning("state", 5000); + this->status_momentary_warning(5000); } }); - this->update_->add_on_state_callback([this]() { this->defer("send", [this]() { this->publish_state(); }); }); + this->update_->add_on_state_callback([this]() { this->defer(SEND_DEFER_ID, [this]() { this->publish_state(); }); }); } bool MQTTUpdateComponent::publish_state() { diff --git a/esphome/components/mqtt/mqtt_valve.cpp b/esphome/components/mqtt/mqtt_valve.cpp index b155a4c897..b41a482de9 100644 --- a/esphome/components/mqtt/mqtt_valve.cpp +++ b/esphome/components/mqtt/mqtt_valve.cpp @@ -9,7 +9,7 @@ namespace esphome::mqtt { -static const char *const TAG = "mqtt.valve"; +ESPHOME_LOG_TAG(TAG, "mqtt.valve"); using namespace esphome::valve; diff --git a/esphome/components/mqtt_subscribe/__init__.py b/esphome/components/mqtt_subscribe/__init__.py index b17f321372..ee58f82fb6 100644 --- a/esphome/components/mqtt_subscribe/__init__.py +++ b/esphome/components/mqtt_subscribe/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "mqtt_subscribe" + mqtt_subscribe_ns = cg.esphome_ns.namespace("mqtt_subscribe") diff --git a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp index afb725feb6..a79216e04d 100644 --- a/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp +++ b/esphome/components/mqtt_subscribe/sensor/mqtt_subscribe_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::mqtt_subscribe { -static const char *const TAG = "mqtt_subscribe.sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt_subscribe.sensor"); void MQTTSubscribeSensor::setup() { mqtt::global_mqtt_client->subscribe( 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 470e08d59a..55992f42f9 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 @@ -7,7 +7,7 @@ namespace esphome::mqtt_subscribe { -static const char *const TAG = "mqtt_subscribe.text_sensor"; +ESPHOME_LOG_TAG(TAG, "mqtt_subscribe.text_sensor"); void MQTTSubscribeTextSensor::setup() { this->parent_->subscribe( diff --git a/esphome/components/ms5611/__init__.py b/esphome/components/ms5611/__init__.py index e69de29bb2..925c57d57b 100644 --- a/esphome/components/ms5611/__init__.py +++ b/esphome/components/ms5611/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ms5611" diff --git a/esphome/components/ms5611/ms5611.cpp b/esphome/components/ms5611/ms5611.cpp index 9b7dcbe653..8f66d704fe 100644 --- a/esphome/components/ms5611/ms5611.cpp +++ b/esphome/components/ms5611/ms5611.cpp @@ -4,7 +4,10 @@ namespace esphome::ms5611 { -static const char *const TAG = "ms5611"; +ESPHOME_LOG_TAG(TAG, "ms5611"); + +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 0; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 1; static const uint8_t MS5611_ADDRESS = 0x77; static const uint8_t MS5611_CMD_ADC_READ = 0x00; @@ -44,7 +47,7 @@ void MS5611Component::update() { return; } - this->set_timeout("temperature", 10, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 10, [this]() { this->read_temperature_(); }); } void MS5611Component::read_temperature_() { uint8_t bytes[3]; @@ -60,7 +63,7 @@ void MS5611Component::read_temperature_() { return; } - this->set_timeout("pressure", 10, [this, raw_temperature]() { this->read_pressure_(raw_temperature); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 10, [this, raw_temperature]() { this->read_pressure_(raw_temperature); }); } void MS5611Component::read_pressure_(uint32_t raw_temperature) { uint8_t bytes[3]; 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/__init__.py b/esphome/components/ms8607/__init__.py index e1cd49ec7b..9fc73f80a9 100644 --- a/esphome/components/ms8607/__init__.py +++ b/esphome/components/ms8607/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@e28eta"] +DOMAIN = "ms8607" diff --git a/esphome/components/ms8607/ms8607.cpp b/esphome/components/ms8607/ms8607.cpp index b9cbdc749b..2e20c4e0ea 100644 --- a/esphome/components/ms8607/ms8607.cpp +++ b/esphome/components/ms8607/ms8607.cpp @@ -7,7 +7,13 @@ namespace esphome::ms8607 { /// TAG used for logging calls -static const char *const TAG = "ms8607"; +ESPHOME_LOG_TAG(TAG, "ms8607"); + +static constexpr uint32_t RESET_TIMEOUT_ID = 0; +static constexpr uint32_t PROM_READ_TIMEOUT_ID = 1; +static constexpr uint32_t TEMPERATURE_TIMEOUT_ID = 2; +static constexpr uint32_t PRESSURE_TIMEOUT_ID = 3; +static constexpr uint32_t HUMIDITY_TIMEOUT_ID = 4; /// Reset the Pressure/Temperature sensor static const uint8_t MS8607_PT_CMD_RESET = 0x1E; @@ -97,7 +103,7 @@ void MS8607Component::try_reset_() { if (--this->reset_attempts_remaining_ > 0) { uint32_t delay = this->reset_interval_; this->reset_interval_ *= 5; - this->set_timeout("reset", delay, [this]() { this->try_reset_(); }); + this->set_timeout(RESET_TIMEOUT_ID, delay, [this]() { this->try_reset_(); }); this->status_set_error(); } else { this->mark_failed(); @@ -110,7 +116,7 @@ void MS8607Component::try_reset_() { this->status_clear_error(); // 15ms delay matches datasheet, Adafruit_MS8607 & SparkFun_PHT_MS8607_Arduino_Library - this->set_timeout("prom-read", 15, [this]() { + this->set_timeout(PROM_READ_TIMEOUT_ID, 15, [this]() { if (this->read_calibration_values_from_prom_()) { this->setup_status_ = SetupStatus::SUCCESSFUL; this->status_clear_error(); @@ -251,7 +257,7 @@ void MS8607Component::request_read_temperature_() { } // datasheet says 17.2ms max conversion time at OSR 8192 - this->set_timeout("temperature", 20, [this]() { this->read_temperature_(); }); + this->set_timeout(TEMPERATURE_TIMEOUT_ID, 20, [this]() { this->read_temperature_(); }); } void MS8607Component::read_temperature_() { @@ -272,7 +278,8 @@ void MS8607Component::request_read_pressure_(uint32_t d2_raw_temperature) { } // datasheet says 17.2ms max conversion time at OSR 8192 - this->set_timeout("pressure", 20, [this, d2_raw_temperature]() { this->read_pressure_(d2_raw_temperature); }); + this->set_timeout(PRESSURE_TIMEOUT_ID, 20, + [this, d2_raw_temperature]() { this->read_pressure_(d2_raw_temperature); }); } void MS8607Component::read_pressure_(uint32_t d2_raw_temperature) { @@ -293,7 +300,7 @@ void MS8607Component::request_read_humidity_(float temperature_float) { } // datasheet says 15.89ms max conversion time at OSR 8192 - this->set_timeout("humidity", 20, [this, temperature_float]() { this->read_humidity_(temperature_float); }); + this->set_timeout(HUMIDITY_TIMEOUT_ID, 20, [this, temperature_float]() { this->read_humidity_(temperature_float); }); } void MS8607Component::read_humidity_(float temperature_float) { 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/msa3xx/__init__.py b/esphome/components/msa3xx/__init__.py index 0beece6710..75260a1b0a 100644 --- a/esphome/components/msa3xx/__init__.py +++ b/esphome/components/msa3xx/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@latonita"] DEPENDENCIES = ["i2c"] +DOMAIN = "msa3xx" MULTI_CONF = True diff --git a/esphome/components/msa3xx/msa3xx.cpp b/esphome/components/msa3xx/msa3xx.cpp index ecde0cb117..78f2b75c9c 100644 --- a/esphome/components/msa3xx/msa3xx.cpp +++ b/esphome/components/msa3xx/msa3xx.cpp @@ -5,7 +5,7 @@ namespace esphome::msa3xx { -static const char *const TAG = "msa3xx"; +ESPHOME_LOG_TAG(TAG, "msa3xx"); const uint8_t MSA_3XX_PART_ID = 0x13; diff --git a/esphome/components/my9231/__init__.py b/esphome/components/my9231/__init__.py index e43454d6ed..6fcb09a347 100644 --- a/esphome/components/my9231/__init__.py +++ b/esphome/components/my9231/__init__.py @@ -12,6 +12,7 @@ from esphome.const import ( from esphome.types import ConfigType AUTO_LOAD = ["output"] +DOMAIN = "my9231" my9231_ns = cg.esphome_ns.namespace("my9231") MY9231OutputComponent = my9231_ns.class_("MY9231OutputComponent", cg.Component) diff --git a/esphome/components/my9231/my9231.cpp b/esphome/components/my9231/my9231.cpp index 0072f7196e..679600eebc 100644 --- a/esphome/components/my9231/my9231.cpp +++ b/esphome/components/my9231/my9231.cpp @@ -4,7 +4,7 @@ namespace esphome::my9231 { -static const char *const TAG = "my9231.output"; +ESPHOME_LOG_TAG(TAG, "my9231.output"); // One-shot select (frame cycle repeat mode / frame cycle One-shot mode) static const uint8_t MY9231_CMD_ONE_SHOT_DISABLE = 0x0 << 6; diff --git a/esphome/components/nau7802/__init__.py b/esphome/components/nau7802/__init__.py index e69de29bb2..d86eed44b8 100644 --- a/esphome/components/nau7802/__init__.py +++ b/esphome/components/nau7802/__init__.py @@ -0,0 +1 @@ +DOMAIN = "nau7802" diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 2092452087..ad1a569e0f 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -5,7 +5,7 @@ namespace esphome::nau7802 { -static const char *const TAG = "nau7802"; +ESPHOME_LOG_TAG(TAG, "nau7802"); // Only define what we need 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/neopixelbus/__init__.py b/esphome/components/neopixelbus/__init__.py index e69de29bb2..0294a2a735 100644 --- a/esphome/components/neopixelbus/__init__.py +++ b/esphome/components/neopixelbus/__init__.py @@ -0,0 +1 @@ +DOMAIN = "neopixelbus" diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 9a47679ba3..1c08202d41 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 @@ -19,6 +21,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["mdns"] +DOMAIN = "network" _LOGGER = logging.getLogger(__name__) @@ -28,6 +31,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 +179,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 @@ -263,7 +278,7 @@ def get_priority_interfaces_from_full_config(full_config: ConfigType) -> set[str """ return { entry["interface"] - for entry in full_config.get("network", {}).get(CONF_PRIORITY, []) + for entry in full_config.get(DOMAIN, {}).get(CONF_PRIORITY, []) } @@ -417,8 +432,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 +469,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/network_component.cpp b/esphome/components/network/network_component.cpp index 7e05983733..fad7a385fd 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -23,7 +23,7 @@ namespace esphome::network { -static const char *const TAG = "network"; +ESPHOME_LOG_TAG(TAG, "network"); void NetworkComponent::setup() { // Initialize ESP-IDF network interfaces and ensure the default event loop exists diff --git a/esphome/components/nextion/__init__.py b/esphome/components/nextion/__init__.py index efb6c88d28..c1ab86427e 100644 --- a/esphome/components/nextion/__init__.py +++ b/esphome/components/nextion/__init__.py @@ -11,6 +11,7 @@ CONF_NEXTION_ID = "nextion_id" CONF_PUBLISH_STATE = "publish_state" CONF_SEND_TO_NEXTION = "send_to_nextion" +DOMAIN = "nextion" FILTER_SOURCE_FILES = filter_source_files_from_platform( { "nextion_upload_esp32.cpp": { diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp index 08e7c58ef1..75e05e2018 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp @@ -4,7 +4,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion_binarysensor"; +ESPHOME_LOG_TAG(TAG, "nextion_binarysensor"); void NextionBinarySensor::process_bool(const std::string &variable_name, bool state) { if (!this->nextion_->is_setup()) diff --git a/esphome/components/nextion/display.py b/esphome/components/nextion/display.py index 73a56e0a42..969a6c8e8c 100644 --- a/esphome/components/nextion/display.py +++ b/esphome/components/nextion/display.py @@ -1,5 +1,3 @@ -import logging - from esphome import automation import esphome.codegen as cg from esphome.components import display, esp32, uart @@ -13,6 +11,7 @@ from esphome.const import ( PLATFORM_ESP8266, ) from esphome.core import CORE, TimePeriod +from esphome.types import ConfigType, TemplateArgsType from . import FILTER_SOURCE_FILES, Nextion, nextion_ns, nextion_ref # noqa: F401 pylint: disable=unused-import from .base_component import ( @@ -43,8 +42,6 @@ from .base_component import ( CONF_WAKE_UP_PAGE, ) -_LOGGER = logging.getLogger(__name__) - CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"] DEPENDENCIES = ["uart"] @@ -56,15 +53,6 @@ def AUTO_LOAD() -> list[str]: return base -def _deprecated_dump_device_info(value): - _LOGGER.warning( - "'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. " - "Device info is now always logged at connection time. " - "Please remove this option from your configuration." - ) - return value - - def _validate_tft_upload(config): has_tft_url = CONF_TFT_URL in config for conf_key in ( @@ -91,9 +79,11 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period_milliseconds, cv.Range(max=TimePeriod(milliseconds=255)), ), - # Deprecated — device info is now always logged. Remove before 2026.11.0. - cv.Optional(CONF_DUMP_DEVICE_INFO): cv.All( - cv.boolean, _deprecated_dump_device_info + # Removed in 2026.11.0 - kept to provide helpful error message + # Remove before 2027.5.0 + cv.Optional(CONF_DUMP_DEVICE_INFO): cv.invalid( + "The 'dump_device_info' option has been removed in ESPHome 2026.11.0.\n" + "Device info is now always logged, so remove this option from your configuration." ), cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean, cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All( @@ -188,6 +178,10 @@ _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_BUFFER_OVERFLOW, "add_buffer_overflow_event_callback" ), +) + +# The strings live only while the callback runs +_CUSTOM_CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_CUSTOM_BINARY_SENSOR, "add_custom_binary_sensor_callback", @@ -271,14 +265,8 @@ 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.add_idf_sdkconfig_option("CONFIG_ESP_TLS_INSECURE", True) - esp32.add_idf_sdkconfig_option( - "CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY", True - ) + esp32.request_http_client() + esp32.allow_insecure_tls() elif CORE.is_esp8266: cg.add_library("ESP8266HTTPClient", None) @@ -310,3 +298,18 @@ async def to_code(config): cg.add_define(define_name) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + for entry in _CUSTOM_CALLBACK_AUTOMATIONS: + for conf in config.get(entry.conf_key, []): + await automation.build_callback_automation( + var, entry.callback_method, _owning_args(entry.args, conf), conf + ) + + +def _owning_args(args: TemplateArgsType, conf: ConfigType) -> TemplateArgsType: + """Pass std::string instead of StringRef to an automation that defers, as it keeps its arguments.""" + if not automation.has_non_synchronous_actions(conf): + return args + return [ + (cg.std_string if type_ is cg.StringRef else type_, name) + for type_, name in args + ] diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 625c915e73..b9194b3f31 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -11,7 +11,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion"; +ESPHOME_LOG_TAG(TAG, "nextion"); // A user entity may be named sleep_wake too; only the internal NO_RESULT command clears the sleeping flag static bool is_sleep_wake_command(const NextionComponentBase *component) { 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..cac1d60594 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -5,7 +5,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion"; +ESPHOME_LOG_TAG(TAG, "nextion"); // Sleep safe commands void Nextion::soft_reset() { this->send_command_("rest"); } @@ -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_upload.cpp b/esphome/components/nextion/nextion_upload.cpp index a49e7f18d6..f106cdf18f 100644 --- a/esphome/components/nextion/nextion_upload.cpp +++ b/esphome/components/nextion/nextion_upload.cpp @@ -6,7 +6,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload"; +ESPHOME_LOG_TAG(TAG, "nextion.upload"); bool Nextion::upload_end_(bool successful) { if (successful) { diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index 944fa1db47..c755a0e9c5 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -13,7 +13,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload"; +ESPHOME_LOG_TAG(TAG, "nextion.upload"); static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index c4dc74b5d3..cabd0761c2 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -16,7 +16,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion.upload"; +ESPHOME_LOG_TAG(TAG, "nextion.upload"); static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; // Timeout for display acknowledgment during TFT upload (ms). diff --git a/esphome/components/nextion/sensor/nextion_sensor.cpp b/esphome/components/nextion/sensor/nextion_sensor.cpp index ca657522f9..bb5206e8d8 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.cpp +++ b/esphome/components/nextion/sensor/nextion_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion_sensor"; +ESPHOME_LOG_TAG(TAG, "nextion_sensor"); void NextionSensor::process_sensor(const std::string &variable_name, int state) { if (!this->nextion_->is_setup()) diff --git a/esphome/components/nextion/switch/nextion_switch.cpp b/esphome/components/nextion/switch/nextion_switch.cpp index 0018cff005..f363666dbf 100644 --- a/esphome/components/nextion/switch/nextion_switch.cpp +++ b/esphome/components/nextion/switch/nextion_switch.cpp @@ -4,7 +4,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion_switch"; +ESPHOME_LOG_TAG(TAG, "nextion_switch"); void NextionSwitch::process_bool(const std::string &variable_name, bool on) { if (!this->nextion_->is_setup()) diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp index 45e4691423..b2ae436684 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp @@ -4,7 +4,7 @@ namespace esphome::nextion { -static const char *const TAG = "nextion_textsensor"; +ESPHOME_LOG_TAG(TAG, "nextion_textsensor"); void NextionTextSensor::process_text(const std::string &variable_name, const std::string &text_value) { if (!this->nextion_->is_setup()) diff --git a/esphome/components/nfc/__init__.py b/esphome/components/nfc/__init__.py index eea1a47b24..a837edead5 100644 --- a/esphome/components/nfc/__init__.py +++ b/esphome/components/nfc/__init__.py @@ -2,12 +2,14 @@ from esphome import automation import esphome.codegen as cg CODEOWNERS = ["@jesserockz", "@kbx81"] +DOMAIN = "nfc" 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..3595f37016 100644 --- a/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp +++ b/esphome/components/nfc/binary_sensor/nfc_binary_sensor.cpp @@ -5,12 +5,9 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.binary_sensor"; +ESPHOME_LOG_TAG(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..552dfe99d3 100644 --- a/esphome/components/nfc/ndef_message.cpp +++ b/esphome/components/nfc/ndef_message.cpp @@ -3,9 +3,9 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.ndef_message"; +ESPHOME_LOG_TAG(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/ndef_record.cpp b/esphome/components/nfc/ndef_record.cpp index 8b9e7023ed..0e8e7ff6ef 100644 --- a/esphome/components/nfc/ndef_record.cpp +++ b/esphome/components/nfc/ndef_record.cpp @@ -2,7 +2,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.ndef_record"; +ESPHOME_LOG_TAG(TAG, "nfc.ndef_record"); NdefRecord::NdefRecord(std::vector payload_data) { this->payload_ = std::string(payload_data.begin(), payload_data.end()); diff --git a/esphome/components/nfc/ndef_record_text.cpp b/esphome/components/nfc/ndef_record_text.cpp index 8ad687daf8..f1e81dcd96 100644 --- a/esphome/components/nfc/ndef_record_text.cpp +++ b/esphome/components/nfc/ndef_record_text.cpp @@ -3,7 +3,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.ndef_record_text"; +ESPHOME_LOG_TAG(TAG, "nfc.ndef_record_text"); NdefRecordText::NdefRecordText(const std::vector &payload) { if (payload.empty()) { diff --git a/esphome/components/nfc/ndef_record_uri.cpp b/esphome/components/nfc/ndef_record_uri.cpp index e2c6d21a34..5d7ebbba48 100644 --- a/esphome/components/nfc/ndef_record_uri.cpp +++ b/esphome/components/nfc/ndef_record_uri.cpp @@ -2,7 +2,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.ndef_record_uri"; +ESPHOME_LOG_TAG(TAG, "nfc.ndef_record_uri"); NdefRecordUri::NdefRecordUri(const std::vector &payload) { if (payload.empty()) { diff --git a/esphome/components/nfc/nfc.cpp b/esphome/components/nfc/nfc.cpp index 76a391f1de..03703bb421 100644 --- a/esphome/components/nfc/nfc.cpp +++ b/esphome/components/nfc/nfc.cpp @@ -5,7 +5,7 @@ namespace esphome::nfc { -static const char *const TAG = "nfc"; +ESPHOME_LOG_TAG(TAG, "nfc"); char *format_uid_to(char *buffer, std::span uid) { return format_hex_pretty_to(buffer, FORMAT_UID_BUFFER_SIZE, uid.data(), uid.size(), '-'); @@ -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..412cc385c6 100644 --- a/esphome/components/nfc/nfc_helpers.cpp +++ b/esphome/components/nfc/nfc_helpers.cpp @@ -2,16 +2,16 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.helpers"; +ESPHOME_LOG_TAG(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.cpp b/esphome/components/nfc/nfc_tag.cpp index c43210517d..0d2d2a2076 100644 --- a/esphome/components/nfc/nfc_tag.cpp +++ b/esphome/components/nfc/nfc_tag.cpp @@ -2,6 +2,6 @@ namespace esphome::nfc { -static const char *const TAG = "nfc.tag"; +ESPHOME_LOG_TAG(TAG, "nfc.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/noblex/__init__.py b/esphome/components/noblex/__init__.py index ceceff9df9..a7c86b064b 100644 --- a/esphome/components/noblex/__init__.py +++ b/esphome/components/noblex/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@AGalfra"] +DOMAIN = "noblex" diff --git a/esphome/components/noblex/noblex.cpp b/esphome/components/noblex/noblex.cpp index ac8d2641fe..a9fb3935f1 100644 --- a/esphome/components/noblex/noblex.cpp +++ b/esphome/components/noblex/noblex.cpp @@ -4,7 +4,7 @@ namespace esphome::noblex { -static const char *const TAG = "noblex.climate"; +ESPHOME_LOG_TAG(TAG, "noblex.climate"); const uint16_t NOBLEX_HEADER_MARK = 9000; const uint16_t NOBLEX_HEADER_SPACE = 4500; diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py index 47cd4cfc67..781079c190 100644 --- a/esphome/components/noise/__init__.py +++ b/esphome/components/noise/__init__.py @@ -1,22 +1,33 @@ import base64 import binascii +import secrets 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.31" +LIBSODIUM_VERSION = "1.10021.12" + noise_ns = cg.esphome_ns.namespace("noise") CONFIG_SCHEMA = cv.Schema({}) +def generate_encryption_key() -> str: + """A fresh random key in the base64 form the yaml takes.""" + return base64.b64encode(secrets.token_bytes(32)).decode() + + def validate_encryption_key(value: Any) -> str: value = cv.string_strict(value) try: @@ -69,18 +80,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 +98,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 86ea1ec874..5e70300f21 100644 --- a/esphome/components/noise/noise.cpp +++ b/esphome/components/noise/noise.cpp @@ -1,6 +1,7 @@ #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" @@ -8,10 +9,11 @@ #include #include +#include namespace esphome::noise { -static const char *const TAG = "noise"; +ESPHOME_LOG_TAG(TAG, "noise"); void NoiseContext::load_psk(psk_t &out) const { if (this->psk_ == nullptr) { @@ -21,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"); 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..181dd63bb2 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -1,12 +1,13 @@ #include "noise_handshake.h" #ifdef USE_NOISE +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::noise { -static const char *const TAG = "noise"; +ESPHOME_LOG_TAG(TAG, "noise"); // Log the failing noise-c call at the same verbosity the api helper used // before this class existed; callers only see one collapsed error code. @@ -57,6 +58,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 +103,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 +112,9 @@ 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; + // The key agreement cannot be split across loop passes + UnavoidableBlockingScope scope; + 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/__init__.py b/esphome/components/npi19/__init__.py index e69de29bb2..190848e54d 100644 --- a/esphome/components/npi19/__init__.py +++ b/esphome/components/npi19/__init__.py @@ -0,0 +1 @@ +DOMAIN = "npi19" diff --git a/esphome/components/npi19/npi19.cpp b/esphome/components/npi19/npi19.cpp index 207acc9d99..2f81bfe3ba 100644 --- a/esphome/components/npi19/npi19.cpp +++ b/esphome/components/npi19/npi19.cpp @@ -5,7 +5,7 @@ namespace esphome::npi19 { -static const char *const TAG = "npi19"; +ESPHOME_LOG_TAG(TAG, "npi19"); static const uint8_t READ_COMMAND = 0xAC; 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 be8768fd15..f2a7825997 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -8,8 +8,11 @@ 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 ( + Section, add_extra_script, copy_files as zephyr_copy_files, zephyr_add_overlay, @@ -25,6 +28,7 @@ from esphome.components.zephyr.const import ( CONF_CDC_ACM, KEY_BOARD, KEY_BOOTLOADER, + KEY_SYSBUILD, KEY_ZEPHYR, CdcAcm, ) @@ -74,6 +78,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 @@ -81,6 +87,7 @@ from .gpio import nrf52_pin_to_code # noqa: F401 CODEOWNERS = ["@tomaszduda23"] AUTO_LOAD = ["zephyr", "preferences"] +DOMAIN = "nrf52" IS_TARGET_PLATFORM = True _LOGGER = logging.getLogger(__name__) @@ -120,11 +127,37 @@ def set_core_data(config: ConfigType) -> ConfigType: CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = KEY_ZEPHYR if config[KEY_BOOTLOADER] in BOOTLOADER_CONFIG: - zephyr_add_pm_static(BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]]) + sections = BOOTLOADER_CONFIG[config[KEY_BOOTLOADER]] + if CORE.testing_mode: + sections = _testing_mode_sections(sections) + zephyr_add_pm_static(sections) return config +# In testing mode, fake a larger flash to allow linking grouped component +# tests. The nRF52840 has 1 MB and a mcumgr OTA config halves the app slot, +# which an openthread config alone fills; CI images are never flashed. +NRF52840_FLASH_SIZE = 0x100000 +TESTING_FLASH_SIZE = 0x400000 + + +def _testing_mode_sections(sections: list[Section]) -> list[Section]: + """Move a bootloader pinned to the end of the real flash to the end of the + faked one, so the partition manager still sees a single gap for the app.""" + return [ + Section( + section.name, + section.address + TESTING_FLASH_SIZE - NRF52840_FLASH_SIZE, + section.size, + section.region, + ) + if section.end_address == NRF52840_FLASH_SIZE + else section + for section in sections + ] + + _TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF) _resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF) @@ -175,6 +208,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 +284,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 +330,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. " @@ -373,6 +408,15 @@ async def to_code(config: ConfigType) -> None: zephyr_setup_preferences() zephyr_to_code(config) + if CORE.testing_mode: + zephyr_add_overlay( + f""" + &flash0 {{ + reg = <0x0 {TESTING_FLASH_SIZE:#x}>; + }}; + """ + ) + if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] @@ -401,7 +445,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 +459,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,9 +472,18 @@ async def to_code(config: ConfigType) -> None: ) zephyr_add_prj_conf("REBOOT", True) - # some boards enable USB by default. + # 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) @@ -445,6 +498,7 @@ async def _dfu_to_code(dfu_config): 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) @@ -482,8 +536,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(): @@ -514,15 +568,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 @@ -570,7 +624,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(): @@ -652,7 +706,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"]), @@ -678,13 +732,13 @@ def upload_program(config: ConfigType, args, host: str) -> bool: return True # Handled: PYOCD upload # Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths - from .ble_logger import is_mac_address + from .ble_logger import is_ble_address from .ota import smpmgr_scan, smpmgr_upload if host == "BLE": mcumgr_device = asyncio.run(smpmgr_scan(CORE.name)) - if is_mac_address(host): + if is_ble_address(host): mcumgr_device = host if mcumgr_device: @@ -699,7 +753,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool: def show_logs(config: ConfigType, args, devices: list[str]) -> bool: address = devices[0] - from .ble_logger import is_mac_address, logger_connect, logger_scan + from .ble_logger import is_ble_address, logger_connect, logger_scan if devices[0] == "BLE": ble_device = asyncio.run(logger_scan(CORE.name)) @@ -708,7 +762,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool: else: return True - if is_mac_address(address): + if is_ble_address(address): asyncio.run(logger_connect(address)) return True return False @@ -770,7 +824,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() @@ -813,6 +935,9 @@ def _generate_cmake_lists() -> bool: ")", ] + if pch_on: + lines += _PCH_CMAKE_LINES + if link_flags: lines += [ "", @@ -832,6 +957,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 @@ -847,9 +992,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) @@ -866,18 +1015,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, @@ -913,13 +1062,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(): @@ -936,12 +1090,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, @@ -949,9 +1103,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/ble_logger.py b/esphome/components/nrf52/ble_logger.py index f74a49ea89..5edcddfd32 100644 --- a/esphome/components/nrf52/ble_logger.py +++ b/esphome/components/nrf52/ble_logger.py @@ -4,6 +4,7 @@ import re from typing import Final from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.backends.scanner import AdvertisementData from bleak.exc import ( BleakCharacteristicNotFoundError, BleakDBusError, @@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E" MAC_ADDRESS_PATTERN: Final = re.compile( r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE ) +# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address +UUID_PATTERN: Final = re.compile( + r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + flags=re.IGNORECASE, +) -def is_mac_address(value: str) -> bool: - return MAC_ADDRESS_PATTERN.match(value) +def is_ble_address(value: str) -> bool: + return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value)) + + +def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool: + # device.name can be a stale cached name on macOS; adv.local_name is what is on the air now + return name in (device.name, adv.local_name) + + +async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None: + return await BleakScanner.find_device_by_filter( + lambda device, adv: _name_matches(device, adv, name), timeout=timeout + ) async def logger_scan(name: str) -> BLEDevice | None: _LOGGER.info("Scanning bluetooth for %s...", name) - device = await BleakScanner.find_device_by_name(name) + device = await find_device_by_name(name) if not device: _LOGGER.error("%s Bluetooth LE device was not found!", name) return device 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 24dee99726..a1d574a034 100644 --- a/esphome/components/nrf52/dfu.cpp +++ b/esphome/components/nrf52/dfu.cpp @@ -10,10 +10,14 @@ namespace esphome::nrf52 { -static const char *const TAG = "dfu"; +ESPHOME_LOG_TAG(TAG, "dfu"); static const uint32_t DFU_DBL_RESET_MAGIC = 0x5A1AD5; // SALADS static const uint8_t DFU_MAGIC_UF2_RESET = 0x57; // Adafruit nRF52 bootloader UF2 magic +static const uint8_t DFU_MAGIC_SKIP = 0x6d; // Adafruit nRF52 bootloader: start the app, skip DFU +// Host-side baud rates that trigger an action; see DeviceFirmwareUpdate::setup(). +static const uint32_t DFU_TOUCH_BAUD_RATE = 1200; // reboot into the bootloader (DFU) +static const uint32_t RESET_TOUCH_BAUD_RATE = 2001; // plain reboot back into the application void DeviceFirmwareUpdate::setup() { if (this->reset_pin_ != nullptr) { @@ -21,13 +25,20 @@ void DeviceFirmwareUpdate::setup() { } #if defined(CONFIG_CDC_ACM_DTE_RATE_CALLBACK_SUPPORT) zephyr::global_cdc_acm->add_on_rate_callback([this](const device *, uint32_t rate) { - if (rate == 1200) { + if (rate == RESET_TOUCH_BAUD_RATE) { + // A plain reboot for host tools (a logs view's Reset device): the USB CDC has no + // reset line, and the bootloader, once entered, has no host command back to the app. + NRF_POWER->GPREGRET = DFU_MAGIC_SKIP; + arch_feed_wdt(); + App.reboot(); + } else if (rate == DFU_TOUCH_BAUD_RATE) { volatile uint32_t *dbl_reset_mem = (volatile uint32_t *) 0x20007F7C; (*dbl_reset_mem) = DFU_DBL_RESET_MAGIC; if (this->reset_pin_ != nullptr) { this->reset_pin_->digital_write(true); } else { NRF_POWER->GPREGRET = DFU_MAGIC_UF2_RESET; + arch_feed_wdt(); App.reboot(); } } diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index 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/ota.py b/esphome/components/nrf52/ota.py index cafeda6478..9115e21874 100644 --- a/esphome/components/nrf52/ota.py +++ b/esphome/components/nrf52/ota.py @@ -4,7 +4,6 @@ import json import logging from pathlib import Path -from bleak import BleakScanner from bleak.exc import BleakDBusError, BleakDeviceNotFoundError from smp.exceptions import SMPBadStartDelimiter from smpclient import SMPClient @@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport from esphome.core import EsphomeError from esphome.espota2 import ProgressBar -from .ble_logger import is_mac_address +from .ble_logger import find_device_by_name, is_ble_address -SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84" BLE_SCAN_TIMEOUT = 10.0 # seconds RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute @@ -45,12 +43,12 @@ def _json_state(o: object) -> object: async def smpmgr_scan(name: str) -> str: _LOGGER.info("Scanning bluetooth for %s...", name) - for device in await BleakScanner.discover( - timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID] - ): - if device.name == name: - return device.address - raise EsphomeError(f"BLE device {name} with OTA service not found") + # No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on. + # The SMP service is checked on connect. + device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT) + if device is None: + raise EsphomeError(f"BLE device {name} not found") + return device.address async def smpmgr_upload(device: str, firmware: Path) -> None: @@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes: async def _smpmgr_upload(device: str, firmware: Path) -> None: image_tlv_sha256 = _get_image_tlv_sha256(firmware) - if is_mac_address(device): + if is_ble_address(device): smp_client = SMPClient(SMPBLETransport(), device) else: smp_client = SMPClient(SMPSerialTransport(), device) @@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None: ) from exc raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc except SMPBLETransportException as exc: - raise EsphomeError(f"Connection error with {device}") from exc + # Reached when a device with the right name has no SMP service, among other causes + raise EsphomeError(f"Connection error with {device}: {exc}") from exc _LOGGER.info("Connected %s...", device) try: 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/ns2009/__init__.py b/esphome/components/ns2009/__init__.py new file mode 100644 index 0000000000..7cd4038a95 --- /dev/null +++ b/esphome/components/ns2009/__init__.py @@ -0,0 +1,8 @@ +import esphome.config_validation as cv + +CODEOWNERS = ["@tchilov"] +DOMAIN = "ns2009" + +CONFIG_SCHEMA = cv.invalid( + "This component should be used as platform of the Touchscreen component." +) diff --git a/esphome/components/ns2009/touchscreen/__init__.py b/esphome/components/ns2009/touchscreen/__init__.py new file mode 100644 index 0000000000..49bcf7a246 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/__init__.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import i2c, touchscreen +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_THRESHOLD +from esphome.types import ConfigType + +CODEOWNERS = ["@tchilov"] +DEPENDENCIES = ["i2c"] + +ns2009_ns = cg.esphome_ns.namespace("ns2009") +NS2009Component = ns2009_ns.class_( + "NS2009Component", + touchscreen.Touchscreen, + i2c.I2CDevice, +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema(calibration_required=True) + .extend( + { + cv.GenerateID(): cv.declare_id(NS2009Component), + cv.Optional(CONF_THRESHOLD, default=40): cv.uint8_t, + } + ) + .extend(i2c.i2c_device_schema(0x48)) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await i2c.register_i2c_device(var, config) + await touchscreen.register_touchscreen(var, config) + + cg.add(var.set_threshold(config[CONF_THRESHOLD])) diff --git a/esphome/components/ns2009/touchscreen/ns2009.cpp b/esphome/components/ns2009/touchscreen/ns2009.cpp new file mode 100644 index 0000000000..ae1bdfe373 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.cpp @@ -0,0 +1,65 @@ +#include "ns2009.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::ns2009 { + +ESPHOME_LOG_TAG(TAG, "ns2009"); + +static constexpr uint8_t GET_X = 0xC0; +static constexpr uint8_t GET_Y = 0xD0; +static constexpr uint8_t GET_Z = 0xE2; + +void NS2009Component::setup() { + if (!this->read_byte(GET_Z).has_value()) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + } +} + +void NS2009Component::update_touches() { + auto data_z = this->read_byte(GET_Z); + if (!data_z.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("pressure")); + this->skip_update_ = true; + return; + } + uint8_t z = *data_z; + if (z <= this->threshold_) { + return; + } + + auto x = this->read_axis_(GET_X); + if (!x.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("X position")); + this->skip_update_ = true; + return; + } + auto y = this->read_axis_(GET_Y); + if (!y.has_value()) { + ESP_LOGW(TAG, "failed to read %s, skipping update", LOG_STR_LITERAL("Y position")); + this->skip_update_ = true; + return; + } + + ESP_LOGV(TAG, "X %4d Y %4d Z %3d", *x, *y, z); + this->add_raw_touch_position_(0, *x, *y, z); +} + +optional NS2009Component::read_axis_(uint8_t cmd) { + auto data = this->read_bytes<2>(cmd); + if (!data.has_value()) { + return {}; + } + return encode_uint16((*data)[0], (*data)[1]) >> 4; // 12 bit followed by 4 0's +} + +void NS2009Component::dump_config() { + ESP_LOGCONFIG(TAG, + "NS2009 Touchscreen:\n" + " Threshold: %u", + this->threshold_); + LOG_I2C_DEVICE(this); +} + +} // namespace esphome::ns2009 diff --git a/esphome/components/ns2009/touchscreen/ns2009.h b/esphome/components/ns2009/touchscreen/ns2009.h new file mode 100644 index 0000000000..75b675bfe9 --- /dev/null +++ b/esphome/components/ns2009/touchscreen/ns2009.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" + +namespace esphome::ns2009 { + +class NS2009Component : public touchscreen::Touchscreen, public i2c::I2CDevice { + public: + /// Set the threshold for the touch detection. + void set_threshold(uint8_t threshold) { this->threshold_ = threshold; } + + void setup() override; + void dump_config() override; + + protected: + void update_touches() override; + /// Read a 12 bit axis value, or nothing when the read fails + optional read_axis_(uint8_t cmd); + + /// @brief The threshold at which a touch is registered. + uint8_t threshold_{40}; +}; + +} // namespace esphome::ns2009 diff --git a/esphome/components/ntc/__init__.py b/esphome/components/ntc/__init__.py index e69de29bb2..6b126fe8d0 100644 --- a/esphome/components/ntc/__init__.py +++ b/esphome/components/ntc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ntc" diff --git a/esphome/components/ntc/ntc.cpp b/esphome/components/ntc/ntc.cpp index 49a42e858e..d2b095a3d7 100644 --- a/esphome/components/ntc/ntc.cpp +++ b/esphome/components/ntc/ntc.cpp @@ -3,7 +3,7 @@ namespace esphome::ntc { -static const char *const TAG = "ntc"; +ESPHOME_LOG_TAG(TAG, "ntc"); void NTC::setup() { this->sensor_->add_on_state_callback([this](float value) { this->process_(value); }); diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index afa2fac483..5822c95c7e 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 = [ @@ -152,6 +154,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ] +DOMAIN = "number" IS_PLATFORM_COMPONENT = True number_ns = cg.esphome_ns.namespace("number") @@ -325,6 +328,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), diff --git a/esphome/components/number/automation.cpp b/esphome/components/number/automation.cpp index a3d49a6ff6..1bca604659 100644 --- a/esphome/components/number/automation.cpp +++ b/esphome/components/number/automation.cpp @@ -3,7 +3,7 @@ namespace esphome::number { -static const char *const TAG = "number.automation"; +ESPHOME_LOG_TAG(TAG, "number.automation"); union convert { float from; diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index ca5aab6469..1d5533c423 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -5,7 +5,7 @@ namespace esphome::number { -static const char *const TAG = "number"; +ESPHOME_LOG_TAG(TAG, "number"); // Function implementation of LOG_NUMBER macro to reduce code size void log_number(const char *tag, const char *prefix, const char *type, Number *obj) { diff --git a/esphome/components/number/number_call.cpp b/esphome/components/number/number_call.cpp index e300ca72de..a928eaf3fa 100644 --- a/esphome/components/number/number_call.cpp +++ b/esphome/components/number/number_call.cpp @@ -4,7 +4,7 @@ namespace esphome::number { -static const char *const TAG = "number"; +ESPHOME_LOG_TAG(TAG, "number"); // Helper functions to reduce code size for logging void NumberCall::log_perform_warning_(const LogString *message) { diff --git a/esphome/components/number/number_traits.cpp b/esphome/components/number/number_traits.cpp index 1e4239ceca..e9559e9177 100644 --- a/esphome/components/number/number_traits.cpp +++ b/esphome/components/number/number_traits.cpp @@ -3,6 +3,6 @@ namespace esphome::number { -static const char *const TAG = "number"; +ESPHOME_LOG_TAG(TAG, "number"); } // namespace esphome::number diff --git a/esphome/components/number/sensor/number_sensor.cpp b/esphome/components/number/sensor/number_sensor.cpp index 227202622a..e90c349f4f 100644 --- a/esphome/components/number/sensor/number_sensor.cpp +++ b/esphome/components/number/sensor/number_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::number { -static const char *const TAG = "number.sensor"; +ESPHOME_LOG_TAG(TAG, "number.sensor"); void NumberSensor::setup() { this->source_->add_on_state_callback([this](float value) { this->publish_state(value); }); diff --git a/esphome/components/one_wire/__init__.py b/esphome/components/one_wire/__init__.py index 8d35aaa34e..69368a990a 100644 --- a/esphome/components/one_wire/__init__.py +++ b/esphome/components/one_wire/__init__.py @@ -5,6 +5,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] +DOMAIN = "one_wire" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/one_wire/one_wire.cpp b/esphome/components/one_wire/one_wire.cpp index aeab821f6a..e950472072 100644 --- a/esphome/components/one_wire/one_wire.cpp +++ b/esphome/components/one_wire/one_wire.cpp @@ -2,20 +2,7 @@ namespace esphome::one_wire { -static const char *const TAG = "one_wire"; - -const std::string &OneWireDevice::get_address_name() { - if (this->address_name_.empty()) { - char hex_buf[19]; // "0x" + 16 hex chars + null - this->address_name_ = format_hex_prefixed_to(hex_buf, this->address_); - } - return this->address_name_; -} - -void OneWireDevice::set_address(uint64_t address) { - this->address_ = address; - this->address_name_.clear(); -} +ESPHOME_LOG_TAG(TAG, "one_wire"); bool OneWireDevice::send_command_(uint8_t cmd) { if (!this->bus_->select(this->address_)) diff --git a/esphome/components/one_wire/one_wire.h b/esphome/components/one_wire/one_wire.h index 4dbbe11792..e3abb95bb7 100644 --- a/esphome/components/one_wire/one_wire.h +++ b/esphome/components/one_wire/one_wire.h @@ -7,14 +7,17 @@ namespace esphome::one_wire { #define LOG_ONE_WIRE_DEVICE(this) \ - ESP_LOGCONFIG(TAG, " Address: %s (%s)", this->get_address_name().c_str(), \ - LOG_STR_ARG(this->bus_->get_model_str(this->address_ & 0xff))); + do { \ + char address_buf[format_hex_prefixed_size(sizeof(uint64_t))]; \ + ESP_LOGCONFIG(TAG, " Address: %s (%s)", format_hex_prefixed_to(address_buf, (this)->address_), \ + LOG_STR_ARG((this)->bus_->get_model_str((this)->address_ & 0xff))); \ + } while (0) class OneWireDevice { public: /// @brief store the address of the device /// @param address of the device - void set_address(uint64_t address); + void set_address(uint64_t address) { this->address_ = address; } void set_index(uint8_t index) { this->index_ = index; } @@ -22,16 +25,12 @@ class OneWireDevice { /// @param bus pointer to the OneWireBus object void set_one_wire_bus(OneWireBus *bus) { this->bus_ = bus; } - /// Helper to create (and cache) the name for this sensor. For example "0xfe0000031f1eaf29". - const std::string &get_address_name(); - protected: static constexpr uint8_t INDEX_NOT_SET = 255; uint64_t address_{0}; uint8_t index_{INDEX_NOT_SET}; OneWireBus *bus_{nullptr}; ///< pointer to OneWireBus instance - std::string address_name_; /// @brief find an address if necessary /// should be called from setup diff --git a/esphome/components/one_wire/one_wire_bus.cpp b/esphome/components/one_wire/one_wire_bus.cpp index b62e4f47d4..22d3afb775 100644 --- a/esphome/components/one_wire/one_wire_bus.cpp +++ b/esphome/components/one_wire/one_wire_bus.cpp @@ -3,13 +3,14 @@ namespace esphome::one_wire { -static const char *const TAG = "one_wire"; +ESPHOME_LOG_TAG(TAG, "one_wire"); static const uint8_t DALLAS_MODEL_DS18S20 = 0x10; static const uint8_t DALLAS_MODEL_DS1822 = 0x22; static const uint8_t DALLAS_MODEL_DS18B20 = 0x28; static const uint8_t DALLAS_MODEL_DS1825 = 0x3B; static const uint8_t DALLAS_MODEL_DS28EA00 = 0x42; +static const uint8_t MAXIM_MODEL_MAX31888 = 0x54; const uint8_t ONE_WIRE_ROM_SELECT = 0x55; const uint8_t ONE_WIRE_ROM_SEARCH = 0xF0; @@ -76,6 +77,8 @@ const LogString *OneWireBus::get_model_str(uint8_t model) { return LOG_STR("DS1825"); case DALLAS_MODEL_DS28EA00: return LOG_STR("DS28EA00"); + case MAXIM_MODEL_MAX31888: + return LOG_STR("MAX31888"); default: return LOG_STR("Unknown"); } diff --git a/esphome/components/online_image/download_buffer.cpp b/esphome/components/online_image/download_buffer.cpp index 2ec6365aa1..c4ea9e5533 100644 --- a/esphome/components/online_image/download_buffer.cpp +++ b/esphome/components/online_image/download_buffer.cpp @@ -4,7 +4,7 @@ namespace esphome::online_image { -static const char *const TAG = "online_image.download_buffer"; +ESPHOME_LOG_TAG(TAG, "online_image.download_buffer"); DownloadBuffer::DownloadBuffer(size_t size) : size_(size) { RAMAllocator allocator; diff --git a/esphome/components/online_image/online_image.cpp b/esphome/components/online_image/online_image.cpp index 3f2382accd..4497d5cad1 100644 --- a/esphome/components/online_image/online_image.cpp +++ b/esphome/components/online_image/online_image.cpp @@ -5,7 +5,7 @@ #include #include -static const char *const TAG = "online_image"; +ESPHOME_LOG_TAG(TAG, "online_image"); static const char *const CONTENT_TYPE_HEADER_NAME = "content-type"; static const char *const ETAG_HEADER_NAME = "etag"; static const char *const IF_NONE_MATCH_HEADER_NAME = "if-none-match"; @@ -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/opentherm/__init__.py b/esphome/components/opentherm/__init__.py index 85632d0bf8..cfcdfe7783 100644 --- a/esphome/components/opentherm/__init__.py +++ b/esphome/components/opentherm/__init__.py @@ -5,12 +5,13 @@ import esphome.codegen as cg from esphome.components import sensor from esphome.components.esp32 import include_builtin_idf_component import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TRIGGER_ID, PLATFORM_ESP32, PLATFORM_ESP8266 +from esphome.const import CONF_ID, PLATFORM_ESP32, PLATFORM_ESP8266 from esphome.core import CORE from . import const, generate, schema, validate CODEOWNERS = ["@olegtarasov"] +DOMAIN = "opentherm" MULTI_CONF = True CONF_IN_PIN = "in_pin" @@ -26,14 +27,16 @@ CONF_SYNC_MODE = "sync_mode" CONF_BEFORE_SEND = "before_send" CONF_BEFORE_PROCESS_RESPONSE = "before_process_response" -# Triggers -BeforeSendTrigger = generate.opentherm_ns.class_( - "BeforeSendTrigger", - automation.Trigger.template(generate.OpenthermData.operator("ref")), -) -BeforeProcessResponseTrigger = generate.opentherm_ns.class_( - "BeforeProcessResponseTrigger", - automation.Trigger.template(generate.OpenthermData.operator("ref")), +_DATA_ARGS = [(generate.OpenthermData.operator("ref"), "x")] +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_BEFORE_SEND, "add_on_before_send_callback", _DATA_ARGS + ), + automation.CallbackAutomation( + CONF_BEFORE_PROCESS_RESPONSE, + "add_on_before_process_response_callback", + _DATA_ARGS, + ), ) CONFIG_SCHEMA = cv.All( @@ -50,17 +53,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_SUMMER_MODE_ACTIVE, False): cv.boolean, cv.Optional(CONF_DHW_BLOCK, False): cv.boolean, cv.Optional(CONF_SYNC_MODE, False): cv.boolean, - cv.Optional(CONF_BEFORE_SEND): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(BeforeSendTrigger), - } - ), + cv.Optional(CONF_BEFORE_SEND): automation.validate_automation({}), cv.Optional(CONF_BEFORE_PROCESS_RESPONSE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - BeforeProcessResponseTrigger - ), - } + {} ), } ) @@ -131,14 +126,4 @@ async def to_code(config: dict[str, Any]) -> None: generate.define_setting_readers(const.SETTING, settings) generate.add_messages(var, settings, schema.SETTINGS) - for conf in config.get(CONF_BEFORE_SEND, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(generate.OpenthermData.operator("ref"), "x")], conf - ) - - for conf in config.get(CONF_BEFORE_PROCESS_RESPONSE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation( - trigger, [(generate.OpenthermData.operator("ref"), "x")], conf - ) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) diff --git a/esphome/components/opentherm/automation.h b/esphome/components/opentherm/automation.h deleted file mode 100644 index 365992b280..0000000000 --- a/esphome/components/opentherm/automation.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "hub.h" -#include "opentherm.h" - -namespace esphome::opentherm { - -class BeforeSendTrigger final : public Trigger { - public: - BeforeSendTrigger(OpenthermHub *hub) { - hub->add_on_before_send_callback([this](OpenthermData &x) { this->trigger(x); }); - } -}; - -class BeforeProcessResponseTrigger final : public Trigger { - public: - BeforeProcessResponseTrigger(OpenthermHub *hub) { - hub->add_on_before_process_response_callback([this](OpenthermData &x) { this->trigger(x); }); - } -}; - -} // namespace esphome::opentherm diff --git a/esphome/components/opentherm/hub.cpp b/esphome/components/opentherm/hub.cpp index f8b515fa05..c253d18671 100644 --- a/esphome/components/opentherm/hub.cpp +++ b/esphome/components/opentherm/hub.cpp @@ -5,7 +5,7 @@ namespace esphome::opentherm { -static const char *const TAG = "opentherm"; +ESPHOME_LOG_TAG(TAG, "opentherm"); namespace message_data { bool parse_flag8_lb_0(OpenthermData &data) { return read_bit(data.valueLB, 0); } bool parse_flag8_lb_1(OpenthermData &data) { return read_bit(data.valueLB, 1); } diff --git a/esphome/components/opentherm/number/opentherm_number.cpp b/esphome/components/opentherm/number/opentherm_number.cpp index d78c6eb38a..f2d8d76f6f 100644 --- a/esphome/components/opentherm/number/opentherm_number.cpp +++ b/esphome/components/opentherm/number/opentherm_number.cpp @@ -2,7 +2,7 @@ namespace esphome::opentherm { -static const char *const TAG = "opentherm.number"; +ESPHOME_LOG_TAG(TAG, "opentherm.number"); void OpenthermNumber::control(float value) { this->publish_state(value); diff --git a/esphome/components/opentherm/opentherm.cpp b/esphome/components/opentherm/opentherm.cpp index 3343871cd0..b8b639167d 100644 --- a/esphome/components/opentherm/opentherm.cpp +++ b/esphome/components/opentherm/opentherm.cpp @@ -20,7 +20,7 @@ namespace esphome::opentherm { using std::string; -static const char *const TAG = "opentherm"; +ESPHOME_LOG_TAG(TAG, "opentherm"); #ifdef ESP8266 OpenTherm *OpenTherm::instance = nullptr; diff --git a/esphome/components/opentherm/output/opentherm_output.cpp b/esphome/components/opentherm/output/opentherm_output.cpp index 9b87cd8d12..adbb1c40c1 100644 --- a/esphome/components/opentherm/output/opentherm_output.cpp +++ b/esphome/components/opentherm/output/opentherm_output.cpp @@ -3,7 +3,7 @@ namespace esphome::opentherm { -static const char *const TAG = "opentherm.output"; +ESPHOME_LOG_TAG(TAG, "opentherm.output"); void opentherm::OpenthermOutput::write_state(float state) { ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_); diff --git a/esphome/components/opentherm/switch/opentherm_switch.cpp b/esphome/components/opentherm/switch/opentherm_switch.cpp index 2fc99e793e..6bf06f825e 100644 --- a/esphome/components/opentherm/switch/opentherm_switch.cpp +++ b/esphome/components/opentherm/switch/opentherm_switch.cpp @@ -2,7 +2,7 @@ namespace esphome::opentherm { -static const char *const TAG = "opentherm.switch"; +ESPHOME_LOG_TAG(TAG, "opentherm.switch"); void OpenthermSwitch::write_state(bool state) { this->publish_state(state); } diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index a71151f3ff..6fca8bfdd9 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -13,12 +13,14 @@ from esphome.components.esp32 import ( get_esp32_variant, include_builtin_idf_component, only_on_variant, + require_mbedtls_ecp, require_mbedtls_tls_extras, require_mbedtls_tls_server, require_vfs_select, ) from esphome.components.mdns import MDNSComponent, enable_mdns_storage -from esphome.components.network import add_use_address +from esphome.components.network import DOMAIN as NETWORK_DOMAIN, add_use_address +from esphome.components.nrf52.framework import include_west_project from esphome.components.zephyr import zephyr_add_prj_conf from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -30,17 +32,17 @@ from esphome.const import ( CONF_LOG_LEVEL, CONF_OUTPUT_POWER, CONF_USE_ADDRESS, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_ESP32, PlatformFramework, ) from esphome.core import ( CORE, - ID, CoroPriority, TimePeriodMilliseconds, coroutine_with_priority, ) -from esphome.cpp_generator import MockObj, TemplateArgsType import esphome.final_validate as fv from esphome.types import ConfigType @@ -60,6 +62,7 @@ from .const import ( ) CODEOWNERS = ["@mrene"] +DOMAIN = "openthread" AUTO_LOAD = ["network"] @@ -111,9 +114,9 @@ def set_sdkconfig_options(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_OPENTHREAD_ENABLED", True) - # OpenThread's DTLS commissioner is a TLS server, and its crypto platform - # uses AES-CCM and deterministic ECDSA directly. Keep the esp32 component - # from trimming them out of mbedTLS. + # Commissioner/joiner Kconfigs default off, so no mbedtls_ssl_* is linked; + # setting one under sdkconfig_options keeps TLS in the build automatically. + # The crypto platform uses AES-CCM and deterministic ECDSA directly. require_mbedtls_tls_server() require_mbedtls_tls_extras( ("CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC") @@ -163,10 +166,10 @@ _CONNECTION_SCHEMA = cv.Schema( { cv.Optional(CONF_PAN_ID): cv.hex_int, cv.Optional(CONF_CHANNEL): cv.int_range(min=11, max=26), - cv.Optional(CONF_NETWORK_KEY): cv.hex_int, + cv.Optional(CONF_NETWORK_KEY): cv.sensitive(cv.hex_int), cv.Optional(CONF_EXT_PAN_ID): cv.hex_int, cv.Optional(CONF_NETWORK_NAME): cv.string_strict, - cv.Optional(CONF_PSKC): cv.hex_int, + cv.Optional(CONF_PSKC): cv.sensitive(cv.hex_int), cv.Optional(CONF_MESH_LOCAL_PREFIX): cv.ipv6network, } ) @@ -240,7 +243,9 @@ CONFIG_SCHEMA = cv.All( *CONF_DEVICE_TYPES, upper=True ), cv.Optional(CONF_FORCE_DATASET): cv.boolean, - cv.Optional(CONF_TLV): cv.All(cv.string_strict, _validate_tlv_hex), + cv.Optional(CONF_TLV): cv.sensitive( + cv.All(cv.string_strict, _validate_tlv_hex) + ), cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_OUTPUT_POWER): cv.All( cv.decibel, @@ -258,7 +263,7 @@ CONFIG_SCHEMA = cv.All( def _final_validate(_: ConfigType) -> None: full_config = fv.full_config.get() - network_config = full_config.get("network", {}) + network_config = full_config.get(NETWORK_DOMAIN, {}) if not network_config.get(CONF_ENABLE_IPV6, False): raise cv.Invalid( "OpenThread requires IPv6 to be enabled in the network component. " @@ -292,6 +297,8 @@ async def to_code(config: ConfigType) -> None: # Re-enable openthread IDF component (excluded by default) if CORE.is_esp32: include_builtin_idf_component("openthread") + # OPENTHREAD_CONFIG_ECDSA_ENABLE: the SRP client host key uses mbedtls_ecdsa_* + require_mbedtls_ecp() cg.add_define("USE_OPENTHREAD") if config.get(CONF_FORCE_DATASET): @@ -319,6 +326,11 @@ async def to_code(config: ConfigType) -> None: if CORE.is_esp32: set_sdkconfig_options(config) elif CORE.using_zephyr: + # Crypto through PSA: mbedtls, plus Oberon from SDK 2.7 + include_west_project("mbedtls") + include_west_project("openthread") + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 7, 0): + include_west_project("oberon-psa-crypto") zephyr_add_prj_conf("NET_L2_OPENTHREAD", True) zephyr_add_prj_conf( f"OPENTHREAD_NORDIC_LIBRARY_{config.get(CONF_DEVICE_TYPE)}", True @@ -328,12 +340,6 @@ async def to_code(config: ConfigType) -> None: # Actions -OpenThreadComponentPollPeriodAction = openthread_ns.class_( - "OpenThreadComponentPollPeriodAction", - automation.Action, - cg.Parented.template(OpenThreadComponent), -) - POLL_PERIOD_ACTION_SCHEMA = automation.maybe_conf( CONF_POLL_PERIOD, cv.Schema( @@ -347,20 +353,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 ae896fcfee..4986850cd4 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -16,7 +16,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "openthread"; +ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { @@ -230,23 +230,27 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) { bool OpenThreadComponent::teardown() { switch (this->teardown_stage_) { case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: { - auto lock = InstanceLock::try_acquire(100); - if (!lock) { - // Try again on next teardown loop - ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown"); - return false; - } - // Start tearing down - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS; - ESP_LOGV(TAG, "Clear SRP"); - otInstance *instance = lock.get_instance(); - otSrpClientClearHostAndServices(instance); - otSrpClientBuffersFreeAllServices(instance); - if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to disable Thread during teardown"); - } - if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) { - ESP_LOGW(TAG, "Failed to disable IPv6 during teardown"); + { + auto lock = InstanceLock::try_acquire(100); + // The OT task may still be starting up; stay pending and retry on + // the next call rather than giving up after a single failed attempt. + if (!lock) { + ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown"); + return false; + } + this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS; + ESP_LOGV(TAG, "Clear SRP"); + otInstance *instance = lock.get_instance(); + otSrpClientClearHostAndServices(instance); + otSrpClientBuffersFreeAllServices(instance); + if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to disable Thread during teardown"); + } + if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to disable IPv6 during teardown"); + } + // Release the lock before stopping -- openthread_stop_() (esp_openthread_stop() on + // ESP32) acquires it internally, and the lock is not recursive. } // Stop OpenThread global_openthread_component = nullptr; @@ -254,11 +258,11 @@ bool OpenThreadComponent::teardown() { int error = this->openthread_stop_(); if (error != 0) { ESP_LOGW(TAG, "Failed attempt to stop OpenThread %d", error); - this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED; } } break; case TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS: - // Waiting on OpenThread stop + // Unreachable today; teardown is synchronous on both platforms. Kept for a future + // graceful-exit path, or a platform whose teardown() cannot be made synchronous. break; case TeardownStage::TEARDOWN_STAGE_COMPLETED: ESP_LOGV(TAG, "OpenThreadComponent Teardown Complete"); @@ -282,6 +286,25 @@ void OpenThreadComponent::on_factory_reset(std::function callback) { ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); } +void OpenThreadComponent::apply_poll_period(uint32_t poll_period) { +#if CONFIG_OPENTHREAD_MTD + this->set_poll_period(poll_period); + if (!this->is_lock_initialized()) { + // The action may run before the stack is up, e.g. from a restore mode; setup() applies the stored value. + ESP_LOGD(TAG, "Not (yet) ready to apply"); + return; + } + auto lock = InstanceLock::try_acquire(100); + if (!lock) { + ESP_LOGW(TAG, "Failed to acquire lock in action"); + return; + } + this->apply_linkmode_(lock.get_instance()); +#else + ESP_LOGW(TAG, "OpenThread action has no effect on FTD devices (MTD only)"); +#endif +} + void OpenThreadComponent::apply_linkmode_(otInstance *instance) { otLinkModeConfig link_mode_config{}; #if CONFIG_OPENTHREAD_FTD diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index b83ffdb6af..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: diff --git a/esphome/components/openthread/openthread_esp.cpp b/esphome/components/openthread/openthread_esp.cpp index 881bbea3c9..f3860b2cc8 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,17 +12,12 @@ #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"; +ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::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..c252034c33 100644 --- a/esphome/components/openthread/openthread_zephyr.cpp +++ b/esphome/components/openthread/openthread_zephyr.cpp @@ -7,7 +7,7 @@ #include "esphome/core/helpers.h" #include -static const char *const TAG = "openthread"; +ESPHOME_LOG_TAG(TAG, "openthread"); namespace esphome::openthread { @@ -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/__init__.py b/esphome/components/openthread_info/__init__.py index e69de29bb2..245d84a104 100644 --- a/esphome/components/openthread_info/__init__.py +++ b/esphome/components/openthread_info/__init__.py @@ -0,0 +1 @@ +DOMAIN = "openthread_info" diff --git a/esphome/components/openthread_info/openthread_info_sensor.cpp b/esphome/components/openthread_info/openthread_info_sensor.cpp index 9beb52d64b..d17dc9a9b6 100644 --- a/esphome/components/openthread_info/openthread_info_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::openthread_info { -static const char *const TAG = "openthread_info"; +ESPHOME_LOG_TAG(TAG, "openthread_info"); void ParentAverageRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Average RSSI", this); } void ParentLastRssiOpenThreadInfo::dump_config() { LOG_SENSOR("", "Parent Last RSSI", this); } diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.cpp b/esphome/components/openthread_info/openthread_info_text_sensor.cpp index fc61ad81b2..68ee263e7c 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.cpp +++ b/esphome/components/openthread_info/openthread_info_text_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::openthread_info { -static const char *const TAG = "openthread_info"; +ESPHOME_LOG_TAG(TAG, "openthread_info"); void IPAddressOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "IPAddress", this); } void RoleOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Role", this); } @@ -16,6 +16,7 @@ void Eui64OpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Eui64", this); } void NetworkNameOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Name", this); } void NetworkKeyOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Network Key", this); } void PanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "PAN ID", this); } +void MeshLocalEidOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Mesh Local EID", this); } void ExtPanIdOpenThreadInfo::dump_config() { LOG_TEXT_SENSOR("", "Extended PAN ID", this); } } // namespace esphome::openthread_info diff --git a/esphome/components/openthread_info/openthread_info_text_sensor.h b/esphome/components/openthread_info/openthread_info_text_sensor.h index ef7c5cc8e9..1f97c57780 100644 --- a/esphome/components/openthread_info/openthread_info_text_sensor.h +++ b/esphome/components/openthread_info/openthread_info_text_sensor.h @@ -134,6 +134,24 @@ class ChannelOpenThreadInfo final : public OpenThreadInstancePollingComponent, p uint8_t last_channel_; }; +class MeshLocalEidOpenThreadInfo final : public OpenThreadInstancePollingComponent, public text_sensor::TextSensor { + public: + void update_instance(otInstance *instance) override { + const otIp6Address *addr = otThreadGetMeshLocalEid(instance); + if (otIp6IsAddressEqual(addr, &this->last_addr_)) { + return; + } + this->last_addr_ = *addr; + char buf[OT_IP6_ADDRESS_STRING_SIZE]; + otIp6AddressToString(addr, buf, sizeof(buf)); + this->publish_state(buf); + } + void dump_config() override; + + protected: + otIp6Address last_addr_{}; +}; + class DatasetOpenThreadInfo : public OpenThreadInstancePollingComponent { public: void update_instance(otInstance *instance) override { diff --git a/esphome/components/openthread_info/text_sensor.py b/esphome/components/openthread_info/text_sensor.py index da789ae706..c7e8a60298 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,10 +11,10 @@ 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" +CONF_MESH_LOCAL_EID = "mesh_local_eid" DEPENDENCIES = ["openthread"] @@ -46,6 +47,9 @@ NetworkKeyOpenThreadInfo = openthread_info_ns.class_( PanIdOpenThreadInfo = openthread_info_ns.class_( "PanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) +MeshLocalEidOpenThreadInfo = openthread_info_ns.class_( + "MeshLocalEidOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent +) ExtPanIdOpenThreadInfo = openthread_info_ns.class_( "ExtPanIdOpenThreadInfo", text_sensor.TextSensor, cg.PollingComponent ) @@ -80,6 +84,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_PAN_ID): text_sensor.text_sensor_schema( # noqa: F821 PanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), + cv.Optional(CONF_MESH_LOCAL_EID): text_sensor.text_sensor_schema( + MeshLocalEidOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), cv.Optional(CONF_EXT_PAN_ID): text_sensor.text_sensor_schema( ExtPanIdOpenThreadInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ).extend(cv.polling_component_schema("1s")), @@ -103,4 +110,5 @@ async def to_code(config: ConfigType) -> None: await setup_conf(config, CONF_NETWORK_NAME) await setup_conf(config, CONF_NETWORK_KEY) await setup_conf(config, CONF_PAN_ID) + await setup_conf(config, CONF_MESH_LOCAL_EID) await setup_conf(config, CONF_EXT_PAN_ID) diff --git a/esphome/components/opt3001/__init__.py b/esphome/components/opt3001/__init__.py index e69de29bb2..efd544b1e2 100644 --- a/esphome/components/opt3001/__init__.py +++ b/esphome/components/opt3001/__init__.py @@ -0,0 +1 @@ +DOMAIN = "opt3001" diff --git a/esphome/components/opt3001/opt3001.cpp b/esphome/components/opt3001/opt3001.cpp index ce5fbdfd82..0101368ceb 100644 --- a/esphome/components/opt3001/opt3001.cpp +++ b/esphome/components/opt3001/opt3001.cpp @@ -3,7 +3,10 @@ namespace esphome::opt3001 { -static const char *const TAG = "opt3001.sensor"; +ESPHOME_LOG_TAG(TAG, "opt3001.sensor"); + +static constexpr uint32_t RESULT_RETRY_TIMEOUT_ID = 0; +static constexpr uint32_t READ_TIMEOUT_ID = 1; static const uint8_t OPT3001_REG_RESULT = 0x00; static const uint8_t OPT3001_REG_CONFIGURATION = 0x01; @@ -42,7 +45,7 @@ void OPT3001Sensor::read_result_(const std::function &f) { if ((raw_value & OPT3001_CONFIGURATION_CONVERSION_MODE_MASK) != OPT3001_CONFIGURATION_CONVERSION_MODE_SHUTDOWN) { // not ready; wait 10ms and try again ESP_LOGW(TAG, "Data not ready; waiting 10ms"); - this->set_timeout("opt3001_wait", 10, [this, f]() { read_result_(f); }); + this->set_timeout(RESULT_RETRY_TIMEOUT_ID, 10, [this, f]() { read_result_(f); }); return; } @@ -70,7 +73,7 @@ void OPT3001Sensor::read_lx_(const std::function &f) { return; } - this->set_timeout("read", OPT3001_CONVERSION_TIME_800, [this, f]() { + this->set_timeout(READ_TIMEOUT_ID, OPT3001_CONVERSION_TIME_800, [this, f]() { if (this->write(&OPT3001_REG_CONFIGURATION, 1) != i2c::ERROR_OK) { ESP_LOGW(TAG, "Starting configuration register read failed"); f(NAN); diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index 6031a68489..f7e03a1699 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -22,6 +22,7 @@ from esphome.types import ConfigType OTA_STATE_LISTENER_KEY = "ota_state_listener" CODEOWNERS = ["@esphome/core"] +DOMAIN = "ota" def AUTO_LOAD() -> list[str]: diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 0447b968a3..a83195d42d 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -1,5 +1,9 @@ #include "ota_backend.h" +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +#include +#endif + namespace esphome::ota { bool version_is_older(const char *candidate, const char *reference) { @@ -30,6 +34,60 @@ bool version_is_older(const char *candidate, const char *reference) { } } +// Non-blocking; may be released by another task than the one that took it. +#ifdef ESPHOME_THREAD_MULTI_ATOMICS +// uint32_t: narrower atomics compile to a masked CAS on Xtensa. +static std::atomic update_locked{0}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +static bool update_lock_test_and_set() { return update_locked.exchange(1) == 0; } +static void update_lock_clear() { update_locked.store(0); } +#else +static bool update_locked = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS +// BK72xx has no atomic exchange. +static Mutex &update_lock_mutex() { + static Mutex mutex; + return mutex; +} +#endif + +static bool update_lock_test_and_set() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#else + // lwIP callbacks may run from an interrupt (RP2040). + InterruptLock guard; +#endif + if (update_locked) + return false; + update_locked = true; + return true; +} + +static void update_lock_clear() { +#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS + LockGuard guard(update_lock_mutex()); +#endif + update_locked = false; +} +#endif + +bool UpdateLock::try_acquire() { + if (!this->held_) + this->held_ = update_lock_test_and_set(); + return this->held_; +} + +void UpdateLock::release() { + if (!this->held_) + return; + update_lock_clear(); + this->held_ = false; +} + +void UpdateLock::force_release() { update_lock_clear(); } + #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index 7348a0ce90..840d1ac11c 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 @@ -50,6 +51,7 @@ enum OTAResponseTypes { OTA_RESPONSE_ERROR_BOOTLOADER_UPDATE = 0x92, OTA_RESPONSE_ERROR_VERSION_DOWNGRADE = 0x93, OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED = 0x94, + OTA_RESPONSE_ERROR_UPDATE_BUSY = 0x95, OTA_RESPONSE_ERROR_UNKNOWN = 0xFF, }; @@ -94,6 +96,29 @@ enum OTAType : uint8_t { OTA_TYPE_UPDATE_BOOTLOADER = 0x02, }; +/// Serializes flash updates. Taken in begin(), released when the backend is destroyed, or kept until +/// reboot after a successful end(). Also for components writing the update partition outside OTA. +class UpdateLock { + public: + UpdateLock() = default; + ~UpdateLock() { this->release(); } + UpdateLock(const UpdateLock &) = delete; + UpdateLock &operator=(const UpdateLock &) = delete; + + /// Returns false if held elsewhere. + bool try_acquire(); + void release(); + bool is_held() const { return this->held_; } + /// Keep the lock set until reboot; is_held() becomes false. + void hold_until_reboot() { this->held_ = false; } + + protected: + /// Tests only. + static void force_release(); + + bool held_{false}; +}; + // The OTA backend method surface. Exactly one backend exists per build, // selected in ota_backend_factory.h where this concept is asserted on // make_ota_backend()'s return type. Semantics beyond the signatures: @@ -102,6 +127,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 +137,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.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index 231c4d2dd2..33af42b6a7 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -9,11 +9,13 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -57,6 +59,7 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -66,7 +69,11 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { return OTA_RESPONSE_ERROR_UPDATE_END; } -void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } +void ArduinoLibreTinyOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; + Update.abort(); +} } // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index c2716a44d1..fc277bb5e4 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -13,9 +13,10 @@ 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: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_arduino_rp2.cpp b/esphome/components/ota/ota_backend_arduino_rp2.cpp index 48725b1265..d4d94be236 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2.cpp @@ -11,11 +11,13 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ArduinoRP2OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -63,6 +65,7 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 // This matches the behavior of the old web_server OTA implementation if (Update.end(!this->md5_set_)) { + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } @@ -73,6 +76,8 @@ OTAResponseTypes ArduinoRP2OTABackend::end() { } void ArduinoRP2OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; Update.end(); rp2::preferences_prevent_write(false); } diff --git a/esphome/components/ota/ota_backend_arduino_rp2.h b/esphome/components/ota/ota_backend_arduino_rp2.h index f7c0037bd2..93b9dcb75c 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2.h +++ b/esphome/components/ota/ota_backend_arduino_rp2.h @@ -15,9 +15,13 @@ 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: + UpdateLock lock_; bool md5_set_{false}; }; diff --git a/esphome/components/ota/ota_backend_esp8266.cpp b/esphome/components/ota/ota_backend_esp8266.cpp index 2a6a9e08b1..3d6148e64f 100644 --- a/esphome/components/ota/ota_backend_esp8266.cpp +++ b/esphome/components/ota/ota_backend_esp8266.cpp @@ -46,11 +46,13 @@ static constexpr size_t MIN_BUFFER_SIZE = 256; namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; } @@ -303,10 +305,13 @@ OTAResponseTypes ESP8266OTABackend::end() { this->buffer_.reset(); esp8266::preferences_prevent_write(false); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void ESP8266OTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; this->buffer_.reset(); this->buffer_len_ = 0; this->image_size_ = 0; diff --git a/esphome/components/ota/ota_backend_esp8266.h b/esphome/components/ota/ota_backend_esp8266.h index 21b5c12c2d..bd4c9a1bce 100644 --- a/esphome/components/ota/ota_backend_esp8266.h +++ b/esphome/components/ota/ota_backend_esp8266.h @@ -20,9 +20,10 @@ 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: + UpdateLock lock_; /// Erase flash sector if current address is at sector boundary bool erase_sector_if_needed_(); diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index f33f37bbeb..fd6fd7f6f1 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -15,11 +15,13 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; #ifdef USE_OTA_PARTITIONS this->ota_type_ = ota_type; if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { @@ -196,7 +198,7 @@ OTAResponseTypes IDFOTABackend::end() { // signature, and only re-points boot at an already-installed app -- so it is // intentionally not run through the signature verifier below. if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) { - return this->update_partition_table(); + return this->hold_lock_if_ok_(this->update_partition_table()); } if (!this->is_app_or_bootloader_update_()) { return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; @@ -209,7 +211,7 @@ OTAResponseTypes IDFOTABackend::end() { } #ifdef USE_OTA_PARTITIONS if (this->ota_type_ == ota::OTA_TYPE_UPDATE_BOOTLOADER) { - return this->finalize_bootloader_update_(err); + return this->hold_lock_if_ok_(this->finalize_bootloader_update_(err)); } #endif if (err == ESP_OK) { @@ -242,7 +244,7 @@ OTAResponseTypes IDFOTABackend::end() { #endif err = esp_ota_set_boot_partition(this->partition_); if (err == ESP_OK) { - return OTA_RESPONSE_OK; + return this->hold_lock_if_ok_(OTA_RESPONSE_OK); } } if (err == ESP_ERR_OTA_VALIDATE_FAILED) { @@ -260,6 +262,8 @@ OTAResponseTypes IDFOTABackend::end() { } void IDFOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; #ifdef USE_OTA_PARTITIONS if (this->partition_table_part_ != nullptr) { esp_partition_deregister_external(this->partition_table_part_); diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index c991f896e8..d166bb361f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -33,9 +33,15 @@ 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: + UpdateLock lock_; + OTAResponseTypes hold_lock_if_ok_(OTAResponseTypes result) { + if (result == OTA_RESPONSE_OK) + this->lock_.hold_until_reboot(); + return result; + } #ifdef USE_OTA_PARTITIONS // copy_dest_part non-null means the running app must be copied INTO this slot of the current // table before the new partition table is committed. The destination is in the current table 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.cpp b/esphome/components/ota/ota_backend_host.cpp index 89e3f99e1e..923b0afe18 100644 --- a/esphome/components/ota/ota_backend_host.cpp +++ b/esphome/components/ota/ota_backend_host.cpp @@ -27,7 +27,7 @@ namespace esphome::ota { namespace { -const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); constexpr size_t MAX_OTA_SIZE = 256u * 1024u * 1024u; // 256 MiB constexpr size_t HEADER_PEEK_SIZE = 64; @@ -200,6 +200,8 @@ bool validate_executable_(const char *staging_path, const std::string &exe_path) std::unique_ptr make_ota_backend() { return make_unique(); } OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) { + if (!this->lock_.try_acquire()) + return OTA_RESPONSE_ERROR_UPDATE_BUSY; if (ota_type != OTA_TYPE_UPDATE_APP) return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE; // 0 = unknown size (web_server multipart); cap at MAX_OTA_SIZE. @@ -318,10 +320,13 @@ OTAResponseTypes HostOTABackend::end() { host::arm_reexec(this->final_path_); this->staging_path_.clear(); ESP_LOGI(TAG, "OTA staged at %s; will re-exec on reboot", this->final_path_.c_str()); + this->lock_.hold_until_reboot(); return OTA_RESPONSE_OK; } void HostOTABackend::abort() { + if (!this->lock_.is_held()) // refused in begin() + return; if (this->fd_ >= 0) { ::close(this->fd_); this->fd_ = -1; diff --git a/esphome/components/ota/ota_backend_host.h b/esphome/components/ota/ota_backend_host.h index 51ffdaeda3..6c86fe10ba 100644 --- a/esphome/components/ota/ota_backend_host.h +++ b/esphome/components/ota/ota_backend_host.h @@ -19,9 +19,10 @@ 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: + UpdateLock lock_; md5::MD5Digest md5_{}; std::string staging_path_; std::string final_path_; diff --git a/esphome/components/ota/ota_bootloader_esp_idf.cpp b/esphome/components/ota/ota_bootloader_esp_idf.cpp index 5a83d92689..a59d010e4c 100644 --- a/esphome/components/ota/ota_bootloader_esp_idf.cpp +++ b/esphome/components/ota/ota_bootloader_esp_idf.cpp @@ -12,7 +12,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); OTAResponseTypes IDFOTABackend::register_and_validate_bootloader_part_() { // Register the bootloader partition diff --git a/esphome/components/ota/ota_partitions_esp_idf.cpp b/esphome/components/ota/ota_partitions_esp_idf.cpp index d2b1196de6..f9ce1b49e1 100644 --- a/esphome/components/ota/ota_partitions_esp_idf.cpp +++ b/esphome/components/ota/ota_partitions_esp_idf.cpp @@ -16,7 +16,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) { return (a_offset + a_size > b_offset && b_offset + b_size > a_offset); diff --git a/esphome/components/ota/ota_signature_esp_idf.cpp b/esphome/components/ota/ota_signature_esp_idf.cpp index 2192a79441..59ffa9edb0 100644 --- a/esphome/components/ota/ota_signature_esp_idf.cpp +++ b/esphome/components/ota/ota_signature_esp_idf.cpp @@ -30,7 +30,7 @@ namespace esphome::ota { -static const char *const TAG = "ota"; +ESPHOME_LOG_TAG(TAG, "ota"); // Route the "Signature check: " prefix (and its per-block form) through one // shared format string each, so the prefix is pooled once by the linker instead diff --git a/esphome/components/output/button/output_button.cpp b/esphome/components/output/button/output_button.cpp index 5833743586..17ffc39d7f 100644 --- a/esphome/components/output/button/output_button.cpp +++ b/esphome/components/output/button/output_button.cpp @@ -3,7 +3,9 @@ namespace esphome::output { -static const char *const TAG = "output.button"; +ESPHOME_LOG_TAG(TAG, "output.button"); + +static constexpr uint32_t RESET_TIMEOUT_ID = 0; void OutputButton::dump_config() { LOG_BUTTON("", "Output Button", this); @@ -12,8 +14,8 @@ void OutputButton::dump_config() { void OutputButton::press_action() { this->output_->turn_on(); - // Use a named timeout so that it's automatically cancelled if button is pressed again before it's reset - this->set_timeout("reset", this->duration_, [this]() { this->output_->turn_off(); }); + // One timeout id, so a second press restarts the reset instead of stacking another + this->set_timeout(RESET_TIMEOUT_ID, this->duration_, [this]() { this->output_->turn_off(); }); } } // namespace esphome::output diff --git a/esphome/components/output/float_output.cpp b/esphome/components/output/float_output.cpp index e2b029f368..8057d0f9f1 100644 --- a/esphome/components/output/float_output.cpp +++ b/esphome/components/output/float_output.cpp @@ -4,7 +4,7 @@ namespace esphome::output { -static const char *const TAG = "output.float"; +ESPHOME_LOG_TAG(TAG, "output.float"); #ifdef USE_OUTPUT_FLOAT_POWER_SCALING void FloatOutput::set_max_power(float max_power) { diff --git a/esphome/components/output/lock/output_lock.cpp b/esphome/components/output/lock/output_lock.cpp index ee9d918542..2f90f602f0 100644 --- a/esphome/components/output/lock/output_lock.cpp +++ b/esphome/components/output/lock/output_lock.cpp @@ -3,7 +3,7 @@ namespace esphome::output { -static const char *const TAG = "output.lock"; +ESPHOME_LOG_TAG(TAG, "output.lock"); void OutputLock::dump_config() { LOG_LOCK("", "Output Lock", this); } diff --git a/esphome/components/output/switch/output_switch.cpp b/esphome/components/output/switch/output_switch.cpp index a21cbc0f6b..a7bbbab5e6 100644 --- a/esphome/components/output/switch/output_switch.cpp +++ b/esphome/components/output/switch/output_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::output { -static const char *const TAG = "output.switch"; +ESPHOME_LOG_TAG(TAG, "output.switch"); void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); } void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); } diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 171c6753ae..577385b217 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,5 +1,6 @@ from collections import UserDict from collections.abc import Callable +from dataclasses import dataclass, field from functools import reduce import logging from pathlib import Path @@ -7,6 +8,7 @@ from typing import Any from esphome import git, yaml_util from esphome.components.substitutions import ( + DOMAIN as SUBSTITUTIONS_DOMAIN, ContextVars, ErrList, push_context, @@ -34,11 +36,45 @@ from esphome.const import ( CONF_VARS, __version__ as ESPHOME_VERSION, ) -from esphome.core import EsphomeError +from esphome.core import CORE, EsphomeError _LOGGER = logging.getLogger(__name__) -DOMAIN = CONF_PACKAGES +DOMAIN = "packages" + + +@dataclass(frozen=True) +class RemotePackageSource: + """A remote source a package was fetched from while processing the config.""" + + url: str + ref: str | None + + +@dataclass +class PackagesData: + """Per-run package state, keyed under DOMAIN in CORE.data.""" + + # Insertion-ordered and de-duplicated + remote_sources: dict[RemotePackageSource, None] = field(default_factory=dict) + + +def _get_data() -> PackagesData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = PackagesData() + return CORE.data[DOMAIN] + + +def get_remote_package_sources() -> tuple[RemotePackageSource, ...]: + """Remote sources fetched for this config, in fetch order. + + store_yaml uses them to tell remote parts of the config from local files. + """ + if (data := CORE.data.get(DOMAIN)) is None: + return () + return tuple(data.remote_sources) + + # Guard against infinite include chains (e.g. A includes B includes A). MAX_INCLUDE_DEPTH = 20 @@ -128,7 +164,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( @@ -192,6 +228,9 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]: username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), ) + _get_data().remote_sources[ + RemotePackageSource(config[CONF_URL], config.get(CONF_REF)) + ] = None files: list[dict[str, Any]] = [] # ``repo_root`` is the directory containing ``.git`` and must be passed @@ -353,7 +392,7 @@ def _walk_packages( packages = config[CONF_PACKAGES] packages_path = (path or []) + [CONF_PACKAGES] - with cv.prepend_path(CONF_PACKAGES): + with cv.prepend_path(DOMAIN): if isinstance(packages, yaml_util.IncludeFile): # If the packages key is an IncludeFile, resolve it first before processing. packages = resolve_include( @@ -599,7 +638,7 @@ def do_packages_pass( if CONF_PACKAGES not in config: return config - with cv.prepend_path(CONF_SUBSTITUTIONS): + with cv.prepend_path(SUBSTITUTIONS_DOMAIN): substitutions = UserDict( resolve_substitutions_block( config.pop(CONF_SUBSTITUTIONS, {}), command_line_substitutions diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 998e1be5fc..f2dc43654a 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -50,7 +50,7 @@ static constexpr size_t PACKET_MAX_LOG_BYTES = 168; * Ping key (4 bytes) * */ -static const char *const TAG = "packet_transport"; +ESPHOME_LOG_TAG(TAG, "packet_transport"); static size_t round4(size_t value) { return (value + 3) & ~3; } diff --git a/esphome/components/partition/__init__.py b/esphome/components/partition/__init__.py index e69de29bb2..7d911eb405 100644 --- a/esphome/components/partition/__init__.py +++ b/esphome/components/partition/__init__.py @@ -0,0 +1 @@ +DOMAIN = "partition" diff --git a/esphome/components/partition/light_partition.cpp b/esphome/components/partition/light_partition.cpp index 2755f82294..6f2645b365 100644 --- a/esphome/components/partition/light_partition.cpp +++ b/esphome/components/partition/light_partition.cpp @@ -3,6 +3,6 @@ namespace esphome::partition { -static const char *const TAG = "partition.light"; +ESPHOME_LOG_TAG(TAG, "partition.light"); } // namespace esphome::partition diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index 47b9118d9a..69d8935977 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -18,6 +18,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["gpio_expander"] +DOMAIN = "pca6416a" MULTI_CONF = True pca6416a_ns = cg.esphome_ns.namespace("pca6416a") @@ -60,7 +61,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/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index 4c19feab6e..f805a8da54 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -20,7 +20,7 @@ enum PCA6416AGPIORegisters { PCAL6416A_PULL_DIR1 = 0x49, }; -static const char *const TAG = "pca6416a"; +ESPHOME_LOG_TAG(TAG, "pca6416a"); void PCA6416AComponent::setup() { // Test to see if device exists diff --git a/esphome/components/pca9554/__init__.py b/esphome/components/pca9554/__init__.py index 5272df2b55..cba5656e88 100644 --- a/esphome/components/pca9554/__init__.py +++ b/esphome/components/pca9554/__init__.py @@ -17,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@hwstar", "@clydebarrow", "@bdraco"] AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] +DOMAIN = "pca9554" MULTI_CONF = True CONF_PIN_COUNT = "pin_count" pca9554_ns = cg.esphome_ns.namespace("pca9554") @@ -66,7 +67,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.cpp b/esphome/components/pca9554/pca9554.cpp index c3ea6a3c0c..f367d14067 100644 --- a/esphome/components/pca9554/pca9554.cpp +++ b/esphome/components/pca9554/pca9554.cpp @@ -9,7 +9,7 @@ const uint8_t OUTPUT_REG = 1; const uint8_t INVERT_REG = 2; const uint8_t CONFIG_REG = 3; -static const char *const TAG = "pca9554"; +ESPHOME_LOG_TAG(TAG, "pca9554"); void PCA9554Component::setup() { this->reg_width_ = (this->pin_count_ + 7) / 8; diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 817cfd1c30..16103aee44 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -10,6 +10,7 @@ from esphome.const import ( from esphome.types import ConfigType DEPENDENCIES = ["i2c"] +DOMAIN = "pca9685" MULTI_CONF = True pca9685_ns = cg.esphome_ns.namespace("pca9685") diff --git a/esphome/components/pca9685/pca9685_output.cpp b/esphome/components/pca9685/pca9685_output.cpp index 533b3391b1..fe417ed65d 100644 --- a/esphome/components/pca9685/pca9685_output.cpp +++ b/esphome/components/pca9685/pca9685_output.cpp @@ -5,7 +5,7 @@ namespace esphome::pca9685 { -static const char *const TAG = "pca9685"; +ESPHOME_LOG_TAG(TAG, "pca9685"); // PCA9685 mode constants are now inline constexpr in pca9685_output.h diff --git a/esphome/components/pcd8544/__init__.py b/esphome/components/pcd8544/__init__.py index e69de29bb2..ff11812a30 100644 --- a/esphome/components/pcd8544/__init__.py +++ b/esphome/components/pcd8544/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pcd8544" diff --git a/esphome/components/pcd8544/pcd_8544.cpp b/esphome/components/pcd8544/pcd_8544.cpp index c80283ffc9..953c4ac19b 100644 --- a/esphome/components/pcd8544/pcd_8544.cpp +++ b/esphome/components/pcd8544/pcd_8544.cpp @@ -5,7 +5,7 @@ namespace esphome::pcd8544 { -static const char *const TAG = "pcd_8544"; +ESPHOME_LOG_TAG(TAG, "pcd_8544"); void PCD8544::setup_pins_() { this->spi_setup(); diff --git a/esphome/components/pcf85063/__init__.py b/esphome/components/pcf85063/__init__.py index e69de29bb2..577cea5e1e 100644 --- a/esphome/components/pcf85063/__init__.py +++ b/esphome/components/pcf85063/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pcf85063" diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index 6ec61770f2..8ce76e9781 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -6,7 +6,7 @@ namespace esphome::pcf85063 { -static const char *const TAG = "pcf85063"; +ESPHOME_LOG_TAG(TAG, "pcf85063"); void PCF85063Component::setup() { if (!this->read_rtc_()) { 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/__init__.py b/esphome/components/pcf8563/__init__.py index e69de29bb2..624ec1be8c 100644 --- a/esphome/components/pcf8563/__init__.py +++ b/esphome/components/pcf8563/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pcf8563" diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index 93c0f2bdf2..90ea4b3fd1 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -6,7 +6,7 @@ namespace esphome::pcf8563 { -static const char *const TAG = "PCF8563"; +ESPHOME_LOG_TAG(TAG, "PCF8563"); void PCF8563Component::setup() { if (!this->read_rtc_()) { 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..6c9ed3cd98 100644 --- a/esphome/components/pcf8574/__init__.py +++ b/esphome/components/pcf8574/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] +DOMAIN = "pcf8574" MULTI_CONF = True pcf8574_ns = cg.esphome_ns.namespace("pcf8574") @@ -63,7 +64,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.cpp b/esphome/components/pcf8574/pcf8574.cpp index 2e054b0683..727e9a2026 100644 --- a/esphome/components/pcf8574/pcf8574.cpp +++ b/esphome/components/pcf8574/pcf8574.cpp @@ -3,7 +3,7 @@ namespace esphome::pcf8574 { -static const char *const TAG = "pcf8574"; +ESPHOME_LOG_TAG(TAG, "pcf8574"); void PCF8574Component::setup() { if (!this->read_gpio_()) { diff --git a/esphome/components/pcm5122/__init__.py b/esphome/components/pcm5122/__init__.py index 81e00ca74b..f79d1eadfc 100644 --- a/esphome/components/pcm5122/__init__.py +++ b/esphome/components/pcm5122/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@remcom"] +DOMAIN = "pcm5122" diff --git a/esphome/components/pcm5122/pcm5122.cpp b/esphome/components/pcm5122/pcm5122.cpp index 4f6417f6c0..0ca851357f 100644 --- a/esphome/components/pcm5122/pcm5122.cpp +++ b/esphome/components/pcm5122/pcm5122.cpp @@ -7,7 +7,7 @@ namespace esphome::pcm5122 { -static const char *const TAG = "pcm5122"; +ESPHOME_LOG_TAG(TAG, "pcm5122"); void PCM5122::setup() { // Hold XSMT low (soft mute asserted) until init completes diff --git a/esphome/components/pcm5122/pcm5122_gpio.cpp b/esphome/components/pcm5122/pcm5122_gpio.cpp index 1aef130457..104b2cb535 100644 --- a/esphome/components/pcm5122/pcm5122_gpio.cpp +++ b/esphome/components/pcm5122/pcm5122_gpio.cpp @@ -5,7 +5,7 @@ namespace esphome::pcm5122 { -static const char *const TAG = "pcm5122.gpio"; +ESPHOME_LOG_TAG(TAG, "pcm5122.gpio"); void PCM5122GPIOPin::setup() { this->pin_mode(this->flags_); } diff --git a/esphome/components/pd_pioneer_ir/__init__.py b/esphome/components/pd_pioneer_ir/__init__.py new file mode 100644 index 0000000000..3ca0ea4f78 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pd_pioneer_ir" diff --git a/esphome/components/pd_pioneer_ir/climate.py b/esphome/components/pd_pioneer_ir/climate.py new file mode 100644 index 0000000000..6bd3cc88d5 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/climate.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +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 + +AUTO_LOAD = ["climate_ir"] +CODEOWNERS = ["@martinsah"] + +pd_pioneer_ir_ns = cg.esphome_ns.namespace("pd_pioneer_ir") +PDPioneerIR = pd_pioneer_ir_ns.class_("PDPioneerIR", climate_ir.ClimateIR) + + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(PDPioneerIR).extend( + { + cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean, + # The unit's auto mode is encoded but untested, so it is opt-in + cv.Optional(climate_ir.CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean, + } +) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("pd_pioneer") # used from C++ + var = await climate_ir.new_climate_ir(config) + cg.add(var.set_fahrenheit(config[CONF_USE_FAHRENHEIT])) diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp b/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp new file mode 100644 index 0000000000..3d07d9006b --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_data.cpp @@ -0,0 +1,111 @@ +#include "pd_pioneer_data.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::pd_pioneer_ir { + +// Default frames stay in flash; the constructor copies them with progmem_memcpy. Even byte 5 is PWR_ON, byte 6 +// MODE_COOL +static const uint8_t DEFAULT_ODD[] PROGMEM = {0x23, 0xCB, 0x26, 0x02, 0x00, 0x40, 0xC0, + 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00}; +static const uint8_t DEFAULT_EVEN[] PROGMEM = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x24, 0x03, + 0x0B, 0x05, 0x00, 0x00, 0x00, 0x80}; + +// Odd positions 5 and 6 and the even position 8 fan code, indexed from CLIMATE_FAN_AUTO to CLIMATE_FAN_HIGH +static const uint8_t FAN_CODES[][3] PROGMEM = { + {0x40, 0x20, 0x00}, {0x60, 0x40, 0x02}, {0x40, 0x60, 0x03}, {0x40, 0xC0, 0x05}}; +static constexpr size_t FAN_CODE_COUNT = sizeof(FAN_CODES) / sizeof(FAN_CODES[0]); + +ControlData::ControlData() : odd_(DEFAULT_ODD, sizeof(DEFAULT_ODD)), even_(DEFAULT_EVEN, sizeof(DEFAULT_EVEN)) {} + +void ControlData::set_temp(float temp_c) { + // The temperature travels as 31 - C in whole degrees at position 7, with bit 2 of position 12 adding a half + temp_c = clamp(temp_c, static_cast(PDPIONEER_TEMPC_MIN), static_cast(PDPIONEER_TEMPC_MAX)); + // Round to a quarter, then down to a half, so Fahrenheit set points encode as before + const int halves = static_cast(lroundf(temp_c * 4)) / 2; + this->even_[7] = static_cast(31 - halves / 2); + this->even_[12] = static_cast(0x80 | ((halves & 1) ? 0x04 : 0x00)); +} + +void ControlData::set_mode(ClimateMode mode) { + switch (mode) { + case ClimateMode::CLIMATE_MODE_OFF: + this->even_[5] = PWR_OFF; + this->even_[6] = MODE_HEAT; + this->even_[7] = 0x0C; + this->even_[12] = 0x80; + return; + case ClimateMode::CLIMATE_MODE_COOL: + this->even_[6] = MODE_COOL; + break; + case ClimateMode::CLIMATE_MODE_DRY: + this->even_[6] = MODE_DRY; + break; + case ClimateMode::CLIMATE_MODE_FAN_ONLY: + this->even_[6] = MODE_FAN_ONLY; + break; + case ClimateMode::CLIMATE_MODE_HEAT: + this->even_[6] = MODE_HEAT; + break; + default: + this->even_[6] = MODE_AUTO; + break; + } + this->even_[5] = PWR_ON; +} + +optional ControlData::get_mode(const PDPioneerData &even) { + if (even[5] == PWR_OFF) + return ClimateMode::CLIMATE_MODE_OFF; + switch (even[6]) { + case MODE_COOL: + return ClimateMode::CLIMATE_MODE_COOL; + case MODE_DRY: + return ClimateMode::CLIMATE_MODE_DRY; + case MODE_FAN_ONLY: + return ClimateMode::CLIMATE_MODE_FAN_ONLY; + case MODE_HEAT: + return ClimateMode::CLIMATE_MODE_HEAT; + case MODE_AUTO: + return ClimateMode::CLIMATE_MODE_HEAT_COOL; + default: + return {}; + } +} + +void ControlData::finalize() { + const size_t idx = this->fan_ >= climate::CLIMATE_FAN_AUTO && this->fan_ <= climate::CLIMATE_FAN_HIGH + ? this->fan_ - climate::CLIMATE_FAN_AUTO + : FAN_CODE_COUNT - 1; + this->odd_[5] = progmem_read_byte(&FAN_CODES[idx][0]); + this->odd_[6] = progmem_read_byte(&FAN_CODES[idx][1]); + this->odd_[7] = this->swing_; + this->even_[8] = static_cast(progmem_read_byte(&FAN_CODES[idx][2]) | + ((this->swing_ & SWING_VERTICAL) ? SWING_VERTICAL_EVEN_MASK : 0)); + this->odd_.finalize(); + this->even_.finalize(); +} + +ClimateFanMode ControlData::get_fan_mode(const PDPioneerData &even) { + const uint8_t code = even[8] & ~SWING_VERTICAL_EVEN_MASK; + for (size_t idx = 0; idx < FAN_CODE_COUNT; idx++) { + if (progmem_read_byte(&FAN_CODES[idx][2]) == code) + return static_cast(climate::CLIMATE_FAN_AUTO + idx); + } + return ClimateFanMode::CLIMATE_FAN_HIGH; +} + +void ControlData::set_swing_mode(climate::ClimateSwingMode mode) { + const bool vertical = mode == climate::CLIMATE_SWING_VERTICAL || mode == climate::CLIMATE_SWING_BOTH; + const bool horizontal = mode == climate::CLIMATE_SWING_HORIZONTAL || mode == climate::CLIMATE_SWING_BOTH; + this->swing_ = static_cast((vertical ? SWING_VERTICAL : 0) | (horizontal ? SWING_HORIZONTAL : 0)); +} + +climate::ClimateSwingMode ControlData::get_swing_mode(const PDPioneerData &even, bool horizontal) { + const bool vertical = (even[8] & SWING_VERTICAL_EVEN_MASK) == SWING_VERTICAL_EVEN_MASK; + if (vertical) + return horizontal ? climate::CLIMATE_SWING_BOTH : climate::CLIMATE_SWING_VERTICAL; + return horizontal ? climate::CLIMATE_SWING_HORIZONTAL : climate::CLIMATE_SWING_OFF; +} + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_data.h b/esphome/components/pd_pioneer_ir/pd_pioneer_data.h new file mode 100644 index 0000000000..ffea27dedd --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_data.h @@ -0,0 +1,69 @@ +#pragma once + +#include "esphome/components/remote_base/pd_pioneer_protocol.h" +#include "esphome/components/climate/climate_mode.h" +#include "esphome/core/helpers.h" + +namespace esphome::pd_pioneer_ir { + +constexpr uint8_t PDPIONEER_TEMPC_MIN = 16; +constexpr uint8_t PDPIONEER_TEMPC_MAX = 31; + +using climate::ClimateMode; +using climate::ClimateFanMode; +using remote_base::PDPioneerData; + +class ControlData { + public: + ControlData(); + void set_temp(float temp_c); + void set_mode(ClimateMode mode); + void set_fan_mode(ClimateFanMode mode) { this->fan_ = mode; } + void set_swing_mode(climate::ClimateSwingMode mode); + void set_eco(bool enabled) { + if (enabled) + this->even_[5] = PWR_ECO; + } + + /// Write the fan and swing positions, which share bytes, then the checksums + void finalize(); + const PDPioneerData &odd() const { return this->odd_; } + const PDPioneerData &even() const { return this->even_; } + + /// Clamped to the set point range, so a frame from a sibling remote cannot publish an impossible value + static float get_temp(const PDPioneerData &even) { + const float temp = ((even[12] & 0x04) ? 31.5f : 31.0f) - static_cast(even[7]); + return clamp(temp, static_cast(PDPIONEER_TEMPC_MIN), static_cast(PDPIONEER_TEMPC_MAX)); + } + /// Empty for a mode byte this remote does not use + static optional get_mode(const PDPioneerData &even); + static bool get_eco(const PDPioneerData &even) { return even[5] == PWR_ECO; } + static ClimateFanMode get_fan_mode(const PDPioneerData &even); + /// Only the odd burst carries horizontal swing; the even burst carries vertical swing + static bool get_horizontal_swing(const PDPioneerData &odd) { return (odd[7] & SWING_HORIZONTAL) == SWING_HORIZONTAL; } + static climate::ClimateSwingMode get_swing_mode(const PDPioneerData &even, bool horizontal); + + protected: + static constexpr uint8_t MODE_HEAT = 0x01; + static constexpr uint8_t MODE_DRY = 0x02; + static constexpr uint8_t MODE_COOL = 0x03; + static constexpr uint8_t MODE_FAN_ONLY = 0x07; + static constexpr uint8_t MODE_AUTO = 0x08; + + static constexpr uint8_t PWR_ON = 0x24; + static constexpr uint8_t PWR_ECO = 0x25; + static constexpr uint8_t PWR_OFF = 0xA0; + + static constexpr uint8_t SWING_VERTICAL = 0x08; + static constexpr uint8_t SWING_HORIZONTAL = 0x90; + /// Vertical swing also sets this mask on even frame position 8 (fan sub-code). + static constexpr uint8_t SWING_VERTICAL_EVEN_MASK = 0x38; + + PDPioneerData odd_; + PDPioneerData even_; + ClimateFanMode fan_{climate::CLIMATE_FAN_HIGH}; + /// Odd-burst position 7: SWING_VERTICAL and SWING_HORIZONTAL flags + uint8_t swing_{0}; +}; + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp new file mode 100644 index 0000000000..4ddcb3bc27 --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.cpp @@ -0,0 +1,59 @@ +#include "pd_pioneer_ir.h" +#include "esphome/core/log.h" + +namespace esphome::pd_pioneer_ir { + +ESPHOME_LOG_TAG(TAG, "pd_pioneer_ir"); + +void PDPioneerIR::setup() { + climate_ir::ClimateIR::setup(); + this->publish_state(); +} + +void PDPioneerIR::transmit_state() { + ControlData data; + data.set_mode(this->mode); + if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_FAN_ONLY) + data.set_temp(this->target_temperature); + data.set_fan_mode(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + data.set_swing_mode(this->swing_mode); + // ECO shares the power byte, so it must not undo an off command + data.set_eco(this->mode != climate::CLIMATE_MODE_OFF && this->preset == climate::CLIMATE_PRESET_ECO); + data.finalize(); + // The remote sends the two bursts as separate transmissions + this->transmit_(data.odd()); + this->transmit_(data.even()); +} + +bool PDPioneerIR::on_receive(remote_base::RemoteReceiveData data) { + auto frame = remote_base::PDPioneerProtocol().decode(data); + if (!frame.has_value()) + return false; + if (frame->is_odd_burst()) { + this->rx_horizontal_swing_ = ControlData::get_horizontal_swing(*frame); + return false; + } + // decode() only returns odd or even bursts; a mode byte this remote never sends is another device's frame + const optional decoded = ControlData::get_mode(*frame); + if (!decoded.has_value()) + return false; + // An auto-mode frame from the remote must not publish HEAT_COOL when the traits leave it out + if (this->get_traits().supports_mode(*decoded)) { + this->mode = *decoded; + } else { + ESP_LOGD(TAG, "Received mode %s is not enabled, keeping the current mode", + LOG_STR_ARG(climate::climate_mode_to_string(*decoded))); + } + // Off and fan-only bursts carry no target temperature + if (*decoded != climate::CLIMATE_MODE_OFF && *decoded != climate::CLIMATE_MODE_FAN_ONLY) + this->target_temperature = ControlData::get_temp(*frame); + this->fan_mode = ControlData::get_fan_mode(*frame); + this->swing_mode = ControlData::get_swing_mode(*frame, this->rx_horizontal_swing_); + // A missed odd burst must not carry this command's horizontal swing into the next one + this->rx_horizontal_swing_ = false; + this->preset = ControlData::get_eco(*frame) ? climate::CLIMATE_PRESET_ECO : climate::CLIMATE_PRESET_NONE; + this->publish_state(); + return true; +} + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h new file mode 100644 index 0000000000..38b325dc1e --- /dev/null +++ b/esphome/components/pd_pioneer_ir/pd_pioneer_ir.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "pd_pioneer_data.h" + +namespace esphome::pd_pioneer_ir { + +class PDPioneerIR : public climate_ir::ClimateIR { + public: + PDPioneerIR() + : climate_ir::ClimateIR(PDPIONEER_TEMPC_MIN, PDPIONEER_TEMPC_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}, + {climate::CLIMATE_PRESET_NONE, climate::CLIMATE_PRESET_ECO}) {} + + void setup() override; + + void set_fahrenheit(bool value) { this->temperature_step_ = value ? 0.5f : 1.0f; } + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + // Horizontal swing from the odd burst, applied to the even burst that follows it + bool rx_horizontal_swing_{false}; +}; + +} // namespace esphome::pd_pioneer_ir diff --git a/esphome/components/pi4ioe5v6408/__init__.py b/esphome/components/pi4ioe5v6408/__init__.py index 11a5dd62bb..cd901cb448 100644 --- a/esphome/components/pi4ioe5v6408/__init__.py +++ b/esphome/components/pi4ioe5v6408/__init__.py @@ -20,6 +20,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["gpio_expander"] CODEOWNERS = ["@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "pi4ioe5v6408" MULTI_CONF = True @@ -74,7 +75,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/pi4ioe5v6408/pi4ioe5v6408.cpp b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp index e8e9530dba..9bc10872d2 100644 --- a/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp +++ b/esphome/components/pi4ioe5v6408/pi4ioe5v6408.cpp @@ -14,7 +14,7 @@ static const uint8_t PI4IOE5V6408_REGISTER_IN_STATE = 0x0F; static const uint8_t PI4IOE5V6408_REGISTER_INTERRUPT_ENABLE_MASK = 0x11; static const uint8_t PI4IOE5V6408_REGISTER_INTERRUPT_STATUS = 0x13; -static const char *const TAG = "pi4ioe5v6408"; +ESPHOME_LOG_TAG(TAG, "pi4ioe5v6408"); void PI4IOE5V6408Component::setup() { if (this->reset_) { diff --git a/esphome/components/pid/__init__.py b/esphome/components/pid/__init__.py index 71a87b6ae5..b7b1c5c7af 100644 --- a/esphome/components/pid/__init__.py +++ b/esphome/components/pid/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@OttoWinter"] +DOMAIN = "pid" diff --git a/esphome/components/pid/climate.py b/esphome/components/pid/climate.py index 11a6fd5e6b..1527fa6f35 100644 --- a/esphome/components/pid/climate.py +++ b/esphome/components/pid/climate.py @@ -3,13 +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, Lambda -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) CONF_DEFAULT_TARGET_TEMPERATURE = "default_target_temperature" @@ -158,9 +156,8 @@ automation.register_apply_action( ) -@automation.register_action( +automation.register_apply_action( "climate.pid.autotune", - PIDAutotuneAction, automation.maybe_simple_id( { cv.Required(CONF_ID): cv.use_id(PIDClimate), @@ -173,20 +170,15 @@ automation.register_apply_action( ): 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_apply_action( diff --git a/esphome/components/pid/pid_autotuner.cpp b/esphome/components/pid/pid_autotuner.cpp index a7ae631956..8b9ce7bbee 100644 --- a/esphome/components/pid/pid_autotuner.cpp +++ b/esphome/components/pid/pid_autotuner.cpp @@ -5,7 +5,7 @@ namespace esphome::pid { -static const char *const TAG = "pid.autotune"; +ESPHOME_LOG_TAG(TAG, "pid.autotune"); /* * # PID Autotuner diff --git a/esphome/components/pid/pid_climate.cpp b/esphome/components/pid/pid_climate.cpp index b386716780..d33a7f7544 100644 --- a/esphome/components/pid/pid_climate.cpp +++ b/esphome/components/pid/pid_climate.cpp @@ -3,7 +3,9 @@ namespace esphome::pid { -static const char *const TAG = "pid.climate"; +ESPHOME_LOG_TAG(TAG, "pid.climate"); + +static constexpr uint32_t AUTOTUNE_PROGRESS_INTERVAL_ID = 0; bool PIDClimate::set_deadband_thresholds(float threshold_low, float threshold_high) { if (threshold_low > threshold_high) { @@ -169,6 +171,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; @@ -183,7 +193,7 @@ void PIDClimate::start_autotune(std::unique_ptr &&autotune) { "Until your sensor provides a reading, the autotuner may display \'nan\'", this->get_name().c_str()); - this->set_interval("autotune-progress", 10000, [this]() { + this->set_interval(AUTOTUNE_PROGRESS_INTERVAL_ID, 10000, [this]() { if (this->autotuner_ != nullptr && !this->autotuner_->is_finished()) this->autotuner_->dump_config(); }); diff --git a/esphome/components/pid/pid_climate.h b/esphome/components/pid/pid_climate.h index e8701c99a4..6fea2bedf3 100644 --- a/esphome/components/pid/pid_climate.h +++ b/esphome/components/pid/pid_climate.h @@ -79,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: @@ -109,27 +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_; -}; - } // namespace esphome::pid diff --git a/esphome/components/pid/sensor/pid_climate_sensor.cpp b/esphome/components/pid/sensor/pid_climate_sensor.cpp index afc77efde3..1fbb42a0b1 100644 --- a/esphome/components/pid/sensor/pid_climate_sensor.cpp +++ b/esphome/components/pid/sensor/pid_climate_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::pid { -static const char *const TAG = "pid.sensor"; +ESPHOME_LOG_TAG(TAG, "pid.sensor"); void PIDClimateSensor::setup() { this->parent_->add_on_pid_computed_callback([this]() { this->update_from_parent_(); }); diff --git a/esphome/components/pipsolar/__init__.py b/esphome/components/pipsolar/__init__.py index b404409145..54337b0b13 100644 --- a/esphome/components/pipsolar/__init__.py +++ b/esphome/components/pipsolar/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] CODEOWNERS = ["@andreashergert1984"] AUTO_LOAD = ["binary_sensor", "text_sensor", "sensor", "switch", "output"] +DOMAIN = "pipsolar" MULTI_CONF = True CONF_PIPSOLAR_ID = "pipsolar_id" 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.cpp b/esphome/components/pipsolar/output/pipsolar_output.cpp index 1af753fce3..21e1a4ffcb 100644 --- a/esphome/components/pipsolar/output/pipsolar_output.cpp +++ b/esphome/components/pipsolar/output/pipsolar_output.cpp @@ -4,7 +4,7 @@ namespace esphome::pipsolar { -static const char *const TAG = "pipsolar.output"; +ESPHOME_LOG_TAG(TAG, "pipsolar.output"); void PipsolarOutput::write_state(float state) { char tmp[16]; 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/pipsolar/pipsolar.cpp b/esphome/components/pipsolar/pipsolar.cpp index bd5733fe74..ea7ebaed0a 100644 --- a/esphome/components/pipsolar/pipsolar.cpp +++ b/esphome/components/pipsolar/pipsolar.cpp @@ -4,7 +4,7 @@ namespace esphome::pipsolar { -static const char *const TAG = "pipsolar"; +ESPHOME_LOG_TAG(TAG, "pipsolar"); void Pipsolar::setup() { this->state_ = STATE_IDLE; @@ -286,6 +286,12 @@ void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const c case POLLING_QMN: handle_qmn_(message); break; + case POLLING_QET: + handle_single_int_(message, this->last_qet_, this->total_pv_generated_energy_); + break; + case POLLING_QLT: + handle_single_int_(message, this->last_qlt_, this->total_output_load_energy_); + break; default: break; } @@ -688,6 +694,17 @@ void Pipsolar::handle_qmn_(const char *message) { } } +// QET and QLT each answer with one integer +void Pipsolar::handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value) { + if (raw) { + raw->publish_state(message); + } + + size_t pos = 0; + this->skip_start_(message, &pos); + this->read_int_sensor_(message, &pos, value); +} + void Pipsolar::skip_start_(const char *message, size_t *pos) { if (message[*pos] == '(') { (*pos)++; diff --git a/esphome/components/pipsolar/pipsolar.h b/esphome/components/pipsolar/pipsolar.h index 06c920a6e4..4695e39829 100644 --- a/esphome/components/pipsolar/pipsolar.h +++ b/esphome/components/pipsolar/pipsolar.h @@ -19,6 +19,8 @@ enum ENUMPollingCommand { POLLING_QPIWS = 4, POLLING_QT = 5, POLLING_QMN = 6, + POLLING_QET = 7, + POLLING_QLT = 8, }; struct PollingCommand { uint8_t *command; @@ -116,6 +118,12 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_SENSOR(pv_ok_condition_for_parallel, QPIRI) PIPSOLAR_SENSOR(pv_power_balance, QPIRI) + // QET value + PIPSOLAR_SENSOR(total_pv_generated_energy, QET) + + // QLT value + PIPSOLAR_SENSOR(total_output_load_energy, QLT) + // QMOD values PIPSOLAR_TEXT_SENSOR(device_mode, QMOD) @@ -175,6 +183,8 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { PIPSOLAR_TEXT_SENSOR(last_qpiws, QPIWS) PIPSOLAR_TEXT_SENSOR(last_qt, QT) PIPSOLAR_TEXT_SENSOR(last_qmn, QMN) + PIPSOLAR_TEXT_SENSOR(last_qet, QET) + PIPSOLAR_TEXT_SENSOR(last_qlt, QLT) PIPSOLAR_SWITCH(output_source_priority_utility_switch, QPIRI) PIPSOLAR_SWITCH(output_source_priority_solar_switch, QPIRI) @@ -213,6 +223,7 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent { void handle_qpiws_(const char *message); void handle_qt_(const char *message); void handle_qmn_(const char *message); + void handle_single_int_(const char *message, text_sensor::TextSensor *raw, sensor::Sensor *value); void skip_start_(const char *message, size_t *pos); void skip_field_(const char *message, size_t *pos); diff --git a/esphome/components/pipsolar/sensor/__init__.py b/esphome/components/pipsolar/sensor/__init__.py index 5a697157b7..02f9e239c7 100644 --- a/esphome/components/pipsolar/sensor/__init__.py +++ b/esphome/components/pipsolar/sensor/__init__.py @@ -8,6 +8,7 @@ from esphome.const import ( DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_BATTERY, DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -17,6 +18,7 @@ from esphome.const import ( ICON_FLASH, ICON_GAUGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, UNIT_HERTZ, @@ -24,6 +26,7 @@ from esphome.const import ( UNIT_VOLT, UNIT_VOLT_AMPS, UNIT_WATT, + UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -80,6 +83,10 @@ CONF_BATTERY_VOLTAGE_OFFSET_FOR_FANS_ON = "battery_voltage_offset_for_fans_on" CONF_EEPROM_VERSION = "eeprom_version" CONF_PV_CHARGING_POWER = "pv_charging_power" +# QET / QLT sensors +CONF_TOTAL_PV_GENERATED_ENERGY = "total_pv_generated_energy" +CONF_TOTAL_OUTPUT_LOAD_ENERGY = "total_output_load_energy" + TYPES = { CONF_GRID_RATING_VOLTAGE: sensor.sensor_schema( unit_of_measurement=UNIT_VOLT, @@ -319,6 +326,18 @@ TYPES = { device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, ), + CONF_TOTAL_PV_GENERATED_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + CONF_TOTAL_OUTPUT_LOAD_ENERGY: sensor.sensor_schema( + unit_of_measurement=UNIT_WATT_HOURS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), } CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/esphome/components/pipsolar/switch/pipsolar_switch.cpp b/esphome/components/pipsolar/switch/pipsolar_switch.cpp index 1eedfed0fd..266df24630 100644 --- a/esphome/components/pipsolar/switch/pipsolar_switch.cpp +++ b/esphome/components/pipsolar/switch/pipsolar_switch.cpp @@ -4,7 +4,7 @@ namespace esphome::pipsolar { -static const char *const TAG = "pipsolar.switch"; +ESPHOME_LOG_TAG(TAG, "pipsolar.switch"); void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); } void PipsolarSwitch::write_state(bool state) { diff --git a/esphome/components/pipsolar/text_sensor/__init__.py b/esphome/components/pipsolar/text_sensor/__init__.py index cc7477395b..0986120dcf 100644 --- a/esphome/components/pipsolar/text_sensor/__init__.py +++ b/esphome/components/pipsolar/text_sensor/__init__.py @@ -15,6 +15,8 @@ CONF_LAST_QFLAG = "last_qflag" CONF_LAST_QPIWS = "last_qpiws" CONF_LAST_QT = "last_qt" CONF_LAST_QMN = "last_qmn" +CONF_LAST_QET = "last_qet" +CONF_LAST_QLT = "last_qlt" TYPES = [ CONF_DEVICE_MODE, @@ -25,6 +27,8 @@ TYPES = [ CONF_LAST_QPIWS, CONF_LAST_QT, CONF_LAST_QMN, + CONF_LAST_QET, + CONF_LAST_QLT, ] CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend( diff --git a/esphome/components/pixoo/__init__.py b/esphome/components/pixoo/__init__.py index b1de57df8f..a2c5172bd1 100644 --- a/esphome/components/pixoo/__init__.py +++ b/esphome/components/pixoo/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jesserockz"] +DOMAIN = "pixoo" diff --git a/esphome/components/pixoo/pixoo.cpp b/esphome/components/pixoo/pixoo.cpp index aa035be347..7e9b0bd89b 100644 --- a/esphome/components/pixoo/pixoo.cpp +++ b/esphome/components/pixoo/pixoo.cpp @@ -8,7 +8,7 @@ namespace esphome::pixoo { -static const char *const TAG = "pixoo"; +ESPHOME_LOG_TAG(TAG, "pixoo"); // Divoom LED-board packet protocol. static constexpr uint8_t PACKET_HEAD = 0xAA; diff --git a/esphome/components/pm1006/__init__.py b/esphome/components/pm1006/__init__.py index e69de29bb2..e79bb3f2a4 100644 --- a/esphome/components/pm1006/__init__.py +++ b/esphome/components/pm1006/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pm1006" diff --git a/esphome/components/pm1006/pm1006.cpp b/esphome/components/pm1006/pm1006.cpp index d4c6824713..2f57c02843 100644 --- a/esphome/components/pm1006/pm1006.cpp +++ b/esphome/components/pm1006/pm1006.cpp @@ -3,7 +3,7 @@ namespace esphome::pm1006 { -static const char *const TAG = "pm1006"; +ESPHOME_LOG_TAG(TAG, "pm1006"); static const uint8_t PM1006_RESPONSE_HEADER[] = {0x16, 0x11, 0x0B}; static const uint8_t PM1006_REQUEST[] = {0x11, 0x02, 0x0B, 0x01, 0xE1}; @@ -43,11 +43,7 @@ void PM1006Component::loop() { } uint8_t PM1006Component::pm1006_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional PM1006Component::check_byte_() const { 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/__init__.py b/esphome/components/pm2005/__init__.py index 3716dd7b5e..bda634dc03 100644 --- a/esphome/components/pm2005/__init__.py +++ b/esphome/components/pm2005/__init__.py @@ -1 +1,3 @@ """PM2005/2105 component for ESPHome.""" + +DOMAIN = "pm2005" diff --git a/esphome/components/pm2005/pm2005.cpp b/esphome/components/pm2005/pm2005.cpp index 54a98bf3ad..784a555e02 100644 --- a/esphome/components/pm2005/pm2005.cpp +++ b/esphome/components/pm2005/pm2005.cpp @@ -3,7 +3,7 @@ namespace esphome::pm2005 { -static const char *const TAG = "pm2005"; +ESPHOME_LOG_TAG(TAG, "pm2005"); // Converts a sensor situation to a human readable string static const LogString *pm2005_get_situation_string(int status) { 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/__init__.py b/esphome/components/pmsa003i/__init__.py index e69de29bb2..e66395e3de 100644 --- a/esphome/components/pmsa003i/__init__.py +++ b/esphome/components/pmsa003i/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pmsa003i" diff --git a/esphome/components/pmsa003i/pmsa003i.cpp b/esphome/components/pmsa003i/pmsa003i.cpp index 0b5c72a94d..6885795e8a 100644 --- a/esphome/components/pmsa003i/pmsa003i.cpp +++ b/esphome/components/pmsa003i/pmsa003i.cpp @@ -5,7 +5,7 @@ namespace esphome::pmsa003i { -static const char *const TAG = "pmsa003i"; +ESPHOME_LOG_TAG(TAG, "pmsa003i"); static const uint8_t COUNT_PAYLOAD_BYTES = 28; static const uint8_t COUNT_PAYLOAD_LENGTH_BYTES = 2; @@ -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/__init__.py b/esphome/components/pmsx003/__init__.py index e69de29bb2..1c60a105dd 100644 --- a/esphome/components/pmsx003/__init__.py +++ b/esphome/components/pmsx003/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pmsx003" diff --git a/esphome/components/pmsx003/pmsx003.cpp b/esphome/components/pmsx003/pmsx003.cpp index f8d890ac9e..f99e5adaa2 100644 --- a/esphome/components/pmsx003/pmsx003.cpp +++ b/esphome/components/pmsx003/pmsx003.cpp @@ -4,7 +4,7 @@ namespace esphome::pmsx003 { -static const char *const TAG = "pmsx003"; +ESPHOME_LOG_TAG(TAG, "pmsx003"); static const uint8_t START_CHARACTER_1 = 0x42; static const uint8_t START_CHARACTER_2 = 0x4D; @@ -189,6 +189,7 @@ bool PMSX003Component::check_payload_length_(uint16_t payload_length) { case Type::PMS5003ST: // Data 16 not set/reserved return payload_length == 36; // 2*17+2 case Type::PMS9003M: + case Type::LUFTMY: return payload_length == 28; // 2*13+2 } return false; @@ -219,40 +220,48 @@ void PMSX003Component::send_command_(Command cmd, uint16_t data) { } void PMSX003Component::parse_data_() { - // Particle Matter + // PM mass concentrations const uint16_t pm_1_0_std_concentration = this->get_16_bit_uint_(4); const uint16_t pm_2_5_std_concentration = this->get_16_bit_uint_(6); const uint16_t pm_10_0_std_concentration = this->get_16_bit_uint_(8); - const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); - const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); - const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); - - const uint16_t pm_particles_03um = this->get_16_bit_uint_(16); - const uint16_t pm_particles_05um = this->get_16_bit_uint_(18); - const uint16_t pm_particles_10um = this->get_16_bit_uint_(20); - const uint16_t pm_particles_25um = this->get_16_bit_uint_(22); - - ESP_LOGD(TAG, - "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " - "Concentration: %u µg/m³, PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 " - "Concentration: %u µg/m³", - pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration, pm_1_0_concentration, - pm_2_5_concentration, pm_10_0_concentration); - if (this->pm_1_0_std_sensor_ != nullptr) this->pm_1_0_std_sensor_->publish_state(pm_1_0_std_concentration); if (this->pm_2_5_std_sensor_ != nullptr) this->pm_2_5_std_sensor_->publish_state(pm_2_5_std_concentration); if (this->pm_10_0_std_sensor_ != nullptr) this->pm_10_0_std_sensor_->publish_state(pm_10_0_std_concentration); + ESP_LOGD(TAG, + "Got PM1.0 Standard Concentration: %u µg/m³, PM2.5 Standard Concentration %u µg/m³, PM10.0 Standard " + "Concentration: %u µg/m³", + pm_1_0_std_concentration, pm_2_5_std_concentration, pm_10_0_std_concentration); - if (this->pm_1_0_sensor_ != nullptr) - this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); - if (this->pm_2_5_sensor_ != nullptr) - this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); - if (this->pm_10_0_sensor_ != nullptr) - this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + // The Luftmy LD10, LD11, LD13, LD15 and LD16 report no atmospheric PM, so their particle counts start at byte 10. + // All PMS types have atmospheric PM at bytes 10-15, so their particle counts start at byte 16. + const bool is_luftmy = this->type_ == Type::LUFTMY; + const uint8_t pc_offset = is_luftmy ? 10 : 16; + + if (!is_luftmy) { + const uint16_t pm_1_0_concentration = this->get_16_bit_uint_(10); + const uint16_t pm_2_5_concentration = this->get_16_bit_uint_(12); + const uint16_t pm_10_0_concentration = this->get_16_bit_uint_(14); + + ESP_LOGD(TAG, "Got PM1.0 Concentration: %u µg/m³, PM2.5 Concentration %u µg/m³, PM10.0 Concentration: %u µg/m³", + pm_1_0_concentration, pm_2_5_concentration, pm_10_0_concentration); + + if (this->pm_1_0_sensor_ != nullptr) + this->pm_1_0_sensor_->publish_state(pm_1_0_concentration); + if (this->pm_2_5_sensor_ != nullptr) + this->pm_2_5_sensor_->publish_state(pm_2_5_concentration); + if (this->pm_10_0_sensor_ != nullptr) + this->pm_10_0_sensor_->publish_state(pm_10_0_concentration); + } + + // Particle counts at pc_offset: >0.3, >0.5, >1.0, >2.5µm + const uint16_t pm_particles_03um = this->get_16_bit_uint_(pc_offset); + const uint16_t pm_particles_05um = this->get_16_bit_uint_(pc_offset + 2); + const uint16_t pm_particles_10um = this->get_16_bit_uint_(pc_offset + 4); + const uint16_t pm_particles_25um = this->get_16_bit_uint_(pc_offset + 6); if (this->pm_particles_03um_sensor_ != nullptr) this->pm_particles_03um_sensor_->publish_state(pm_particles_03um); @@ -262,22 +271,17 @@ void PMSX003Component::parse_data_() { this->pm_particles_10um_sensor_->publish_state(pm_particles_10um); if (this->pm_particles_25um_sensor_ != nullptr) this->pm_particles_25um_sensor_->publish_state(pm_particles_25um); + ESP_LOGD(TAG, + "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " + "PM2.5 Particles: %u Count/0.1L", + pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - if (this->type_ == Type::PMS5003T) { - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um); - } else { - // Note the pm particles 50um & 100um are not returned, - // as PMS5003T uses those data values for temperature and humidity. - const uint16_t pm_particles_50um = this->get_16_bit_uint_(24); - const uint16_t pm_particles_100um = this->get_16_bit_uint_(26); + // PMS5003T uses pc_offset+8 and pc_offset+10 for temperature/humidity, not particle counts + if (this->type_ != Type::PMS5003T) { + const uint16_t pm_particles_50um = this->get_16_bit_uint_(pc_offset + 8); + const uint16_t pm_particles_100um = this->get_16_bit_uint_(pc_offset + 10); - ESP_LOGD(TAG, - "Got PM0.3 Particles: %u Count/0.1L, PM0.5 Particles: %u Count/0.1L, PM1.0 Particles: %u Count/0.1L, " - "PM2.5 Particles %u Count/0.1L, PM5.0 Particles: %u Count/0.1L, PM10.0 Particles %u Count/0.1L", - pm_particles_03um, pm_particles_05um, pm_particles_10um, pm_particles_25um, pm_particles_50um, + ESP_LOGD(TAG, "Got PM5.0 Particles: %u Count/0.1L, PM10.0 Particles: %u Count/0.1L", pm_particles_50um, pm_particles_100um); if (this->pm_particles_50um_sensor_ != nullptr) diff --git a/esphome/components/pmsx003/pmsx003.h b/esphome/components/pmsx003/pmsx003.h index c62960e7c3..c123a42b9e 100644 --- a/esphome/components/pmsx003/pmsx003.h +++ b/esphome/components/pmsx003/pmsx003.h @@ -15,6 +15,7 @@ enum class Type : uint8_t { PMS5003T, PMS5003ST, PMS9003M, + LUFTMY, // Luftmy LD10, LD11, LD13, LD15, LD16: standard PM + particle counts, no atmospheric PM values }; enum class Command : uint8_t { diff --git a/esphome/components/pmsx003/sensor.py b/esphome/components/pmsx003/sensor.py index dc85380203..723b99a504 100644 --- a/esphome/components/pmsx003/sensor.py +++ b/esphome/components/pmsx003/sensor.py @@ -51,6 +51,7 @@ TYPE_PMS5003S = "PMS5003S" TYPE_PMS5003T = "PMS5003T" TYPE_PMS5003ST = "PMS5003ST" TYPE_PMS9003M = "PMS9003M" +TYPE_LUFTMY = "LUFTMY" Type = pmsx003_ns.enum("Type", is_class=True) @@ -62,6 +63,7 @@ PMSX003_TYPES = { TYPE_PMS5003T: Type.PMS5003T, TYPE_PMS5003ST: Type.PMS5003ST, TYPE_PMS9003M: Type.PMS9003M, + TYPE_LUFTMY: Type.LUFTMY, } SENSORS_TO_TYPE = { @@ -73,6 +75,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5_STD: [ TYPE_PMS1003, @@ -82,6 +85,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0_STD: [ TYPE_PMS1003, @@ -91,6 +95,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0: [ TYPE_PMS1003, @@ -126,6 +131,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_0_5UM: [ TYPE_PMS1003, @@ -134,6 +140,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_1_0UM: [ TYPE_PMS1003, @@ -142,6 +149,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_2_5UM: [ TYPE_PMS1003, @@ -150,6 +158,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003T, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_5_0UM: [ TYPE_PMS1003, @@ -157,6 +166,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_PM_10_0UM: [ TYPE_PMS1003, @@ -164,6 +174,7 @@ SENSORS_TO_TYPE = { TYPE_PMS5003S, TYPE_PMS5003ST, TYPE_PMS9003M, + TYPE_LUFTMY, ], CONF_FORMALDEHYDE: [TYPE_PMS5003S, TYPE_PMS5003ST], CONF_TEMPERATURE: [TYPE_PMS5003T, TYPE_PMS5003ST], @@ -323,64 +334,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/__init__.py b/esphome/components/pmwcs3/__init__.py index b04c30a005..64229140cb 100644 --- a/esphome/components/pmwcs3/__init__.py +++ b/esphome/components/pmwcs3/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@SeByDocKy"] +DOMAIN = "pmwcs3" diff --git a/esphome/components/pmwcs3/pmwcs3.cpp b/esphome/components/pmwcs3/pmwcs3.cpp index 94c0c30766..7493f5d97f 100644 --- a/esphome/components/pmwcs3/pmwcs3.cpp +++ b/esphome/components/pmwcs3/pmwcs3.cpp @@ -20,7 +20,7 @@ static const uint8_t PMWCS3_REG_RES = 0x0B; static const uint8_t PMWCS3_REG_RC = 0x0C; static const uint8_t PMWCS3_REG_RT = 0x0D; -static const char *const TAG = "pmwcs3"; +ESPHOME_LOG_TAG(TAG, "pmwcs3"); void PMWCS3Component::new_i2c_address(uint8_t address) { if (!this->write_byte(PMWCS3_SET_I2C_ADDRESS, address)) { 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..b8efeb213c 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -3,18 +3,17 @@ 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"] AUTO_LOAD = ["binary_sensor", "nfc"] +DOMAIN = "pn532" MULTI_CONF = True CONF_PN532_ID = "pn532_id" @@ -22,10 +21,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 +47,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 +64,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..8fd92c9031 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" @@ -11,7 +14,7 @@ namespace esphome::pn532 { -static const char *const TAG = "pn532"; +ESPHOME_LOG_TAG(TAG, "pn532"); void PN532::setup() { // Get version data @@ -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..878e07825d 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" @@ -6,17 +8,17 @@ namespace esphome::pn532 { -static const char *const TAG = "pn532.mifare_classic"; +ESPHOME_LOG_TAG(TAG, "pn532.mifare_classic"); std::unique_ptr PN532::read_mifare_classic_tag_(nfc::NfcTagUid &uid) { uint8_t current_block = 4; 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..afb60bd843 100644 --- a/esphome/components/pn532/pn532_mifare_ultralight.cpp +++ b/esphome/components/pn532/pn532_mifare_ultralight.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -6,10 +7,10 @@ namespace esphome::pn532 { -static const char *const TAG = "pn532.mifare_ultralight"; +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/pn532_i2c/__init__.py index 16b877b1f1..bd2a9373d7 100644 --- a/esphome/components/pn532_i2c/__init__.py +++ b/esphome/components/pn532_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn532"] CODEOWNERS = ["@OttoWinter", "@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "pn532_i2c" MULTI_CONF = True pn532_i2c_ns = cg.esphome_ns.namespace("pn532_i2c") diff --git a/esphome/components/pn532_i2c/pn532_i2c.cpp b/esphome/components/pn532_i2c/pn532_i2c.cpp index 7f4d78461b..5326aeb145 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 @@ -9,33 +11,37 @@ namespace esphome::pn532_i2c { -static const char *const TAG = "pn532_i2c"; +ESPHOME_LOG_TAG(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,29 @@ 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; + const uint8_t checksum = -sum_bytes(data.data() + tfi_offset, len + 1U); - uint8_t checksum = 0; - for (int i = 0; i < len + 1; i++) { - uint8_t dat = data[i]; - checksum += dat; - } - 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/__init__.py b/esphome/components/pn532_spi/__init__.py index 734f794180..0e24425f76 100644 --- a/esphome/components/pn532_spi/__init__.py +++ b/esphome/components/pn532_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn532"] CODEOWNERS = ["@OttoWinter", "@jesserockz"] DEPENDENCIES = ["spi"] +DOMAIN = "pn532_spi" MULTI_CONF = True pn532_spi_ns = cg.esphome_ns.namespace("pn532_spi") diff --git a/esphome/components/pn532_spi/pn532_spi.cpp b/esphome/components/pn532_spi/pn532_spi.cpp index 13d9aebc20..34c6a3e44f 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 @@ -9,7 +11,7 @@ namespace esphome::pn532_spi { -static const char *const TAG = "pn532_spi"; +ESPHOME_LOG_TAG(TAG, "pn532_spi"); // Maximum bytes to log in verbose hex output static constexpr size_t PN532_MAX_LOG_BYTES = 64; @@ -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)]; @@ -123,12 +127,8 @@ bool PN532Spi::read_response(uint8_t command, std::vector &data) { ESP_LOGV(TAG, "Response data: %s", format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - uint8_t checksum = header[5] + header[6]; // TFI + Command response code - for (int i = 0; i < len - 1; i++) { - uint8_t dat = data[i]; - checksum += dat; - } - checksum = ~checksum + 1; + // TFI + Command response code + data + const uint8_t checksum = -sum_bytes(data.data(), len - 1, header[5] + header[6]); if (data[len - 1] != checksum) { ESP_LOGV(TAG, "read data invalid checksum! %02X != %02X", data[len - 1], checksum); @@ -140,7 +140,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..4fbe07e2e3 100644 --- a/esphome/components/pn7150/__init__.py +++ b/esphome/components/pn7150/__init__.py @@ -1,240 +1,24 @@ -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" +DOMAIN = "pn7150" 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..16bbac38e4 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 -} +ESPHOME_LOG_TAG(TAG, "pn7150"); 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/__init__.py b/esphome/components/pn7150_i2c/__init__.py index d15c0b431e..c0dfb4c2e4 100644 --- a/esphome/components/pn7150_i2c/__init__.py +++ b/esphome/components/pn7150_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn7150"] CODEOWNERS = ["@kbx81", "@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "pn7150_i2c" pn7150_i2c_ns = cg.esphome_ns.namespace("pn7150_i2c") PN7150I2C = pn7150_i2c_ns.class_("PN7150I2C", pn7150.PN7150, i2c.I2CDevice) diff --git a/esphome/components/pn7150_i2c/pn7150_i2c.cpp b/esphome/components/pn7150_i2c/pn7150_i2c.cpp index a61bd27c64..a87bd7d32e 100644 --- a/esphome/components/pn7150_i2c/pn7150_i2c.cpp +++ b/esphome/components/pn7150_i2c/pn7150_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::pn7150_i2c { -static const char *const TAG = "pn7150_i2c"; +ESPHOME_LOG_TAG(TAG, "pn7150_i2c"); uint8_t PN7150I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { if (this->wait_for_irq_(timeout) != nfc::STATUS_OK) { @@ -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..f1bd6122ff 100644 --- a/esphome/components/pn7160/__init__.py +++ b/esphome/components/pn7160/__init__.py @@ -1,252 +1,38 @@ -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"] +DOMAIN = "pn7160" 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..7460f7cb6f 100644 --- a/esphome/components/pn7160/pn7160.cpp +++ b/esphome/components/pn7160/pn7160.cpp @@ -1,261 +1,47 @@ -#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 { -static const char *const TAG = "pn7160"; +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/pn7160_i2c/__init__.py index d711c3e48f..f9add96bdf 100644 --- a/esphome/components/pn7160_i2c/__init__.py +++ b/esphome/components/pn7160_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn7160"] CODEOWNERS = ["@kbx81", "@jesserockz"] DEPENDENCIES = ["i2c"] +DOMAIN = "pn7160_i2c" pn7160_i2c_ns = cg.esphome_ns.namespace("pn7160_i2c") PN7160I2C = pn7160_i2c_ns.class_("PN7160I2C", pn7160.PN7160, i2c.I2CDevice) diff --git a/esphome/components/pn7160_i2c/pn7160_i2c.cpp b/esphome/components/pn7160_i2c/pn7160_i2c.cpp index c34cf90e68..c9da1f4ad2 100644 --- a/esphome/components/pn7160_i2c/pn7160_i2c.cpp +++ b/esphome/components/pn7160_i2c/pn7160_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::pn7160_i2c { -static const char *const TAG = "pn7160_i2c"; +ESPHOME_LOG_TAG(TAG, "pn7160_i2c"); uint8_t PN7160I2C::read_nfcc(nfc::NciMessage &rx, const uint16_t timeout) { if (this->wait_for_irq_(timeout) != nfc::STATUS_OK) { @@ -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/__init__.py b/esphome/components/pn7160_spi/__init__.py index 7705dfa6f6..32c1008f72 100644 --- a/esphome/components/pn7160_spi/__init__.py +++ b/esphome/components/pn7160_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["pn7160"] CODEOWNERS = ["@kbx81", "@jesserockz"] DEPENDENCIES = ["spi"] +DOMAIN = "pn7160_spi" MULTI_CONF = True pn7160_spi_ns = cg.esphome_ns.namespace("pn7160_spi") diff --git a/esphome/components/pn7160_spi/pn7160_spi.cpp b/esphome/components/pn7160_spi/pn7160_spi.cpp index f3c413e952..2914b11e48 100644 --- a/esphome/components/pn7160_spi/pn7160_spi.cpp +++ b/esphome/components/pn7160_spi/pn7160_spi.cpp @@ -3,11 +3,10 @@ namespace esphome::pn7160_spi { -static const char *const TAG = "pn7160_spi"; +ESPHOME_LOG_TAG(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..3b40eb1141 --- /dev/null +++ b/esphome/components/pn71xx/__init__.py @@ -0,0 +1,159 @@ +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"] +DOMAIN = "pn71xx" + +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..469700e01e --- /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 { + +ESPHOME_LOG_TAG(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..e1e805d108 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"; +ESPHOME_LOG_TAG(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"; +ESPHOME_LOG_TAG(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/power_management/__init__.py b/esphome/components/power_management/__init__.py new file mode 100644 index 0000000000..5afbb648be --- /dev/null +++ b/esphome/components/power_management/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +IS_PLATFORM_COMPONENT = True +CODEOWNERS = ["@rwrozelle"] +DOMAIN = "power_management" + +power_management_ns = cg.esphome_ns.namespace("power_management") +PowerManagementComponent = power_management_ns.class_( + "PowerManagementComponent", cg.Component +) + + +async def register_power_management(var: MockObj, config: ConfigType) -> None: + await cg.register_component(var, config) diff --git a/esphome/components/power_management/power_management.h b/esphome/components/power_management/power_management.h new file mode 100644 index 0000000000..a0c026162a --- /dev/null +++ b/esphome/components/power_management/power_management.h @@ -0,0 +1,9 @@ +#pragma once + +#include "esphome/core/component.h" + +namespace esphome::power_management { + +class PowerManagementComponent : public Component {}; + +} // namespace esphome::power_management diff --git a/esphome/components/power_supply/__init__.py b/esphome/components/power_supply/__init__.py index b68118b352..4d90642b5f 100644 --- a/esphome/components/power_supply/__init__.py +++ b/esphome/components/power_supply/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "power_supply" power_supply_ns = cg.esphome_ns.namespace("power_supply") PowerSupply = power_supply_ns.class_("PowerSupply", cg.Component) MULTI_CONF = True diff --git a/esphome/components/power_supply/power_supply.cpp b/esphome/components/power_supply/power_supply.cpp index f094f6e2e9..8fa1b92c25 100644 --- a/esphome/components/power_supply/power_supply.cpp +++ b/esphome/components/power_supply/power_supply.cpp @@ -3,11 +3,18 @@ namespace esphome::power_supply { -static const char *const TAG = "power_supply"; +ESPHOME_LOG_TAG(TAG, "power_supply"); + +static constexpr uint32_t POWER_OFF_TIMEOUT_ID = 0; void PowerSupply::setup() { this->pin_->setup(); - this->pin_->digital_write(false); + if (this->pin_->is_held()) { + // Rail stayed on across the reset; drop it unless something asks for it + this->schedule_off_if_idle_(); + } else { + this->pin_->digital_write(false); + } if (this->enable_on_boot_) this->request_high_power(); } @@ -31,7 +38,7 @@ bool PowerSupply::is_enabled() const { return this->active_requests_ != 0; } void PowerSupply::request_high_power() { if (this->active_requests_ == 0) { - this->cancel_timeout("power-supply-off"); + this->cancel_timeout(POWER_OFF_TIMEOUT_ID); ESP_LOGV(TAG, "Enabling"); this->pin_->digital_write(true); delay(this->enable_time_); @@ -45,14 +52,21 @@ void PowerSupply::unrequest_high_power() { return; } this->active_requests_--; + this->schedule_off_if_idle_(); +} + +void PowerSupply::schedule_off_if_idle_() { if (this->active_requests_ == 0) { - this->set_timeout("power-supply-off", this->keep_on_time_, [this]() { + this->set_timeout(POWER_OFF_TIMEOUT_ID, this->keep_on_time_, [this]() { ESP_LOGV(TAG, "Disabling"); this->pin_->digital_write(false); }); } } + void PowerSupply::on_powerdown() { + if (this->pin_->get_hold()) + return; this->active_requests_ = 0; this->pin_->digital_write(false); } diff --git a/esphome/components/power_supply/power_supply.h b/esphome/components/power_supply/power_supply.h index eaf77af32e..dc6b368ea8 100644 --- a/esphome/components/power_supply/power_supply.h +++ b/esphome/components/power_supply/power_supply.h @@ -39,6 +39,7 @@ class PowerSupply final : public Component { uint32_t keep_on_time_; int16_t active_requests_{0}; // use signed integer to make catching negative requests easier. bool enable_on_boot_{false}; + void schedule_off_if_idle_(); }; class PowerSupplyRequester { diff --git a/esphome/components/preferences/__init__.py b/esphome/components/preferences/__init__.py index abd050ec3b..142cfb32dc 100644 --- a/esphome/components/preferences/__init__.py +++ b/esphome/components/preferences/__init__.py @@ -7,6 +7,7 @@ from esphome.coroutine import CoroPriority from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "preferences" preferences_ns = cg.esphome_ns.namespace("preferences") IntervalSyncer = preferences_ns.class_("IntervalSyncer", cg.PollingComponent) diff --git a/esphome/components/prometheus/__init__.py b/esphome/components/prometheus/__init__.py index 3624fb2442..8bf0163fa7 100644 --- a/esphome/components/prometheus/__init__.py +++ b/esphome/components/prometheus/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_types import EntityBase from esphome.types import ConfigType AUTO_LOAD = ["web_server_base"] +DOMAIN = "prometheus" prometheus_ns = cg.esphome_ns.namespace("prometheus") PrometheusHandler = prometheus_ns.class_("PrometheusHandler", cg.Component) diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 6e51707ccd..00c2e04310 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -322,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; diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index 5574039e41..9dcbf7e686 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -6,8 +6,8 @@ #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" diff --git a/esphome/components/provisioning/provisioning.cpp b/esphome/components/provisioning/provisioning.cpp index 02c089bfed..0089bdcbb7 100644 --- a/esphome/components/provisioning/provisioning.cpp +++ b/esphome/components/provisioning/provisioning.cpp @@ -10,7 +10,7 @@ namespace esphome::provisioning { -static const char *const TAG = "provisioning"; +ESPHOME_LOG_TAG(TAG, "provisioning"); ProvisioningManager *global_provisioning_manager = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) nullptr; diff --git a/esphome/components/psram/psram.cpp b/esphome/components/psram/psram.cpp index ab680c9695..55b6873d05 100644 --- a/esphome/components/psram/psram.cpp +++ b/esphome/components/psram/psram.cpp @@ -9,7 +9,7 @@ #include namespace esphome::psram { -static const char *const TAG = "psram"; +ESPHOME_LOG_TAG(TAG, "psram"); void PsramComponent::dump_config() { ESP_LOGCONFIG(TAG, "PSRAM:"); diff --git a/esphome/components/pulse_counter/__init__.py b/esphome/components/pulse_counter/__init__.py index e69de29bb2..9efceae6de 100644 --- a/esphome/components/pulse_counter/__init__.py +++ b/esphome/components/pulse_counter/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pulse_counter" diff --git a/esphome/components/pulse_counter/pulse_counter_sensor.cpp b/esphome/components/pulse_counter/pulse_counter_sensor.cpp index 13bf3baf83..b104016fb8 100644 --- a/esphome/components/pulse_counter/pulse_counter_sensor.cpp +++ b/esphome/components/pulse_counter/pulse_counter_sensor.cpp @@ -9,7 +9,7 @@ namespace esphome::pulse_counter { -static const char *const TAG = "pulse_counter"; +ESPHOME_LOG_TAG(TAG, "pulse_counter"); const char *const EDGE_MODE_TO_STRING[] = {"DISABLE", "INCREMENT", "DECREMENT"}; diff --git a/esphome/components/pulse_counter/sensor.py b/esphome/components/pulse_counter/sensor.py index 677cfb6a5e..4cf88bafe9 100644 --- a/esphome/components/pulse_counter/sensor.py +++ b/esphome/components/pulse_counter/sensor.py @@ -140,9 +140,8 @@ 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_apply_action( diff --git a/esphome/components/pulse_meter/__init__.py b/esphome/components/pulse_meter/__init__.py index e69de29bb2..7c4fef139c 100644 --- a/esphome/components/pulse_meter/__init__.py +++ b/esphome/components/pulse_meter/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pulse_meter" diff --git a/esphome/components/pulse_meter/pulse_meter_sensor.cpp b/esphome/components/pulse_meter/pulse_meter_sensor.cpp index d6959d1a96..c2d4c648ce 100644 --- a/esphome/components/pulse_meter/pulse_meter_sensor.cpp +++ b/esphome/components/pulse_meter/pulse_meter_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::pulse_meter { -static const char *const TAG = "pulse_meter"; +ESPHOME_LOG_TAG(TAG, "pulse_meter"); void PulseMeterSensor::set_total_pulses(uint32_t pulses) { this->total_pulses_ = pulses; diff --git a/esphome/components/pulse_meter/sensor.py b/esphome/components/pulse_meter/sensor.py index 2df1ba96e9..e523ebe756 100644 --- a/esphome/components/pulse_meter/sensor.py +++ b/esphome/components/pulse_meter/sensor.py @@ -92,9 +92,8 @@ 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_apply_action( diff --git a/esphome/components/pulse_width/__init__.py b/esphome/components/pulse_width/__init__.py index e69de29bb2..97cf2e1c39 100644 --- a/esphome/components/pulse_width/__init__.py +++ b/esphome/components/pulse_width/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pulse_width" diff --git a/esphome/components/pulse_width/pulse_width.cpp b/esphome/components/pulse_width/pulse_width.cpp index 5209ed5352..53896cbacd 100644 --- a/esphome/components/pulse_width/pulse_width.cpp +++ b/esphome/components/pulse_width/pulse_width.cpp @@ -3,7 +3,7 @@ namespace esphome::pulse_width { -static const char *const TAG = "pulse_width"; +ESPHOME_LOG_TAG(TAG, "pulse_width"); void IRAM_ATTR PulseWidthSensorStore::gpio_intr(PulseWidthSensorStore *arg) { const bool new_level = arg->pin_.digital_read(); diff --git a/esphome/components/pvvx_mithermometer/__init__.py b/esphome/components/pvvx_mithermometer/__init__.py index e69de29bb2..56767de804 100644 --- a/esphome/components/pvvx_mithermometer/__init__.py +++ b/esphome/components/pvvx_mithermometer/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pvvx_mithermometer" diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index 64b8974901..995c4aff50 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -7,7 +7,9 @@ namespace esphome::pvvx_mithermometer { -static const char *const TAG = "display.pvvx_mithermometer"; +ESPHOME_LOG_TAG(TAG, "display.pvvx_mithermometer"); + +static constexpr uint32_t DISCONNECT_TIMEOUT_ID = 0; void PVVXDisplay::dump_config() { char service_buf[ble_device_base::UUID_STR_LEN]; @@ -39,7 +41,7 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t case ESP_GATTC_DISCONNECT_EVT: ESP_LOGV(TAG, "[%s] Disconnected", this->parent_->address_str()); this->connection_established_ = false; - this->cancel_timeout("disconnect"); + this->cancel_timeout(DISCONNECT_TIMEOUT_ID); this->char_handle_ = 0; break; case ESP_GATTC_SEARCH_CMPL_EVT: { @@ -147,8 +149,8 @@ void PVVXDisplay::send_to_setup_char_(uint8_t *blk, size_t size) { void PVVXDisplay::delayed_disconnect_() { if (this->disconnect_delay_ms_ == 0) return; - this->cancel_timeout("disconnect"); - this->set_timeout("disconnect", this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); + this->cancel_timeout(DISCONNECT_TIMEOUT_ID); + this->set_timeout(DISCONNECT_TIMEOUT_ID, this->disconnect_delay_ms_, [this]() { this->parent_->set_enabled(false); }); } void PVVXDisplay::sync_time_and_display_() { diff --git a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp index 9141d12b16..0d7f440f15 100644 --- a/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp +++ b/esphome/components/pvvx_mithermometer/pvvx_mithermometer.cpp @@ -3,7 +3,7 @@ namespace esphome::pvvx_mithermometer { -static const char *const TAG = "pvvx_mithermometer"; +ESPHOME_LOG_TAG(TAG, "pvvx_mithermometer"); void PVVXMiThermometer::dump_config() { ESP_LOGCONFIG(TAG, "PVVX MiThermometer"); 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/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 242a613a6c..f9eef4c809 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -10,6 +10,7 @@ _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@functionpointer"] DEPENDENCIES = ["uart"] +DOMAIN = "pylontech" MULTI_CONF = True CONF_PYLONTECH_ID = "pylontech_id" diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 932b71ba55..0898b92486 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -26,7 +26,7 @@ namespace esphome::pylontech { -static const char *const TAG = "pylontech"; +ESPHOME_LOG_TAG(TAG, "pylontech"); static const int MAX_DATA_LENGTH_BYTES = 256; static const uint8_t ASCII_LF = 0x0A; diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index e2def28be5..dc236f47a5 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::pylontech { -static const char *const TAG = "pylontech.sensor"; +ESPHOME_LOG_TAG(TAG, "pylontech.sensor"); PylontechSensor::PylontechSensor(int8_t bat_num) { this->bat_num_ = bat_num; } diff --git a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp index a7c9db1599..1beb04064e 100644 --- a/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp +++ b/esphome/components/pylontech/text_sensor/pylontech_text_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::pylontech { -static const char *const TAG = "pylontech.textsensor"; +ESPHOME_LOG_TAG(TAG, "pylontech.textsensor"); PylontechTextSensor::PylontechTextSensor(int8_t bat_num) { this->bat_num_ = bat_num; } diff --git a/esphome/components/pzem004t/__init__.py b/esphome/components/pzem004t/__init__.py index e69de29bb2..f8d5b7a0c1 100644 --- a/esphome/components/pzem004t/__init__.py +++ b/esphome/components/pzem004t/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pzem004t" diff --git a/esphome/components/pzem004t/pzem004t.cpp b/esphome/components/pzem004t/pzem004t.cpp index a28b448340..606a03e0c2 100644 --- a/esphome/components/pzem004t/pzem004t.cpp +++ b/esphome/components/pzem004t/pzem004t.cpp @@ -5,7 +5,7 @@ namespace esphome::pzem004t { -static const char *const TAG = "pzem004t"; +ESPHOME_LOG_TAG(TAG, "pzem004t"); void PZEM004T::setup() { // Clear UART buffer @@ -34,9 +34,7 @@ void PZEM004T::loop() { // 1-5: data // 6: checksum (sum of other bytes) // see https://github.com/olehs/PZEM004T - uint8_t sum = 0; - for (int i = 0; i < 6; i++) - sum += resp[i]; + const uint8_t sum = sum_bytes(resp.data(), 6); if (sum != resp[6]) { ESP_LOGV(TAG, "PZEM004T invalid checksum! 0x%02X != 0x%02X", sum, resp[6]); 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..45ecb06188 --- /dev/null +++ b/esphome/components/pzem6l24/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pzem6l24" diff --git a/esphome/components/pzem6l24/pzem6l24.cpp b/esphome/components/pzem6l24/pzem6l24.cpp new file mode 100644 index 0000000000..57331a639c --- /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 { + +ESPHOME_LOG_TAG(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_sensor_, 0, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_b_sensor_, 2, REG_U16, SCALE_DECI}, + {&PZEM6L24::voltage_c_sensor_, 4, REG_U16, SCALE_DECI}, + // Currents (×0.01 A) + {&PZEM6L24::current_a_sensor_, 6, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_b_sensor_, 8, REG_U16, SCALE_CENTI}, + {&PZEM6L24::current_c_sensor_, 10, REG_U16, SCALE_CENTI}, + // Frequency (×0.01 Hz) + {&PZEM6L24::frequency_sensor_, 12, REG_U16, SCALE_CENTI}, + // Active powers (×0.1 W, signed) + {&PZEM6L24::active_power_a_sensor_, 28, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_b_sensor_, 32, REG_I32, SCALE_DECI}, + {&PZEM6L24::active_power_c_sensor_, 36, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_active_power_sensor_, 64, REG_I32, SCALE_DECI}, + // Reactive powers (×0.1 var, signed) + {&PZEM6L24::reactive_power_a_sensor_, 40, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_b_sensor_, 44, REG_I32, SCALE_DECI}, + {&PZEM6L24::reactive_power_c_sensor_, 48, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_reactive_power_sensor_, 68, REG_I32, SCALE_DECI}, + // Apparent powers (×0.1 VA, signed) + {&PZEM6L24::apparent_power_a_sensor_, 52, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_b_sensor_, 56, REG_I32, SCALE_DECI}, + {&PZEM6L24::apparent_power_c_sensor_, 60, REG_I32, SCALE_DECI}, + {&PZEM6L24::total_apparent_power_sensor_, 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_sensor_, 77, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_b_sensor_, 76, REG_U8, SCALE_CENTI}, + {&PZEM6L24::power_factor_c_sensor_, 79, REG_U8, SCALE_CENTI}, + {&PZEM6L24::total_power_factor_sensor_, 78, REG_U8, SCALE_CENTI}, + // Active energies (×0.1 kWh, unsigned) + {&PZEM6L24::active_energy_a_sensor_, 80, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_b_sensor_, 84, REG_U32, SCALE_DECI}, + {&PZEM6L24::active_energy_c_sensor_, 88, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_active_energy_sensor_, 116, REG_U32, SCALE_DECI}, + // Reactive energies (×0.1 kvarh, unsigned) + {&PZEM6L24::reactive_energy_a_sensor_, 92, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_b_sensor_, 96, REG_U32, SCALE_DECI}, + {&PZEM6L24::reactive_energy_c_sensor_, 100, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_reactive_energy_sensor_, 120, REG_U32, SCALE_DECI}, + // Apparent energies (×0.1 kVAh, unsigned) + {&PZEM6L24::apparent_energy_a_sensor_, 104, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_b_sensor_, 108, REG_U32, SCALE_DECI}, + {&PZEM6L24::apparent_energy_c_sensor_, 112, REG_U32, SCALE_DECI}, + {&PZEM6L24::total_apparent_energy_sensor_, 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_sensor_); + LOG_SENSOR(" ", "Voltage B", this->voltage_b_sensor_); + LOG_SENSOR(" ", "Voltage C", this->voltage_c_sensor_); + LOG_SENSOR(" ", "Current A", this->current_a_sensor_); + LOG_SENSOR(" ", "Current B", this->current_b_sensor_); + LOG_SENSOR(" ", "Current C", this->current_c_sensor_); + LOG_SENSOR(" ", "Active Power A", this->active_power_a_sensor_); + LOG_SENSOR(" ", "Active Power B", this->active_power_b_sensor_); + LOG_SENSOR(" ", "Active Power C", this->active_power_c_sensor_); + LOG_SENSOR(" ", "Reactive Power A", this->reactive_power_a_sensor_); + LOG_SENSOR(" ", "Reactive Power B", this->reactive_power_b_sensor_); + LOG_SENSOR(" ", "Reactive Power C", this->reactive_power_c_sensor_); + LOG_SENSOR(" ", "Apparent Power A", this->apparent_power_a_sensor_); + LOG_SENSOR(" ", "Apparent Power B", this->apparent_power_b_sensor_); + LOG_SENSOR(" ", "Apparent Power C", this->apparent_power_c_sensor_); + LOG_SENSOR(" ", "Power Factor A", this->power_factor_a_sensor_); + LOG_SENSOR(" ", "Power Factor B", this->power_factor_b_sensor_); + LOG_SENSOR(" ", "Power Factor C", this->power_factor_c_sensor_); + LOG_SENSOR(" ", "Active Energy A", this->active_energy_a_sensor_); + LOG_SENSOR(" ", "Active Energy B", this->active_energy_b_sensor_); + LOG_SENSOR(" ", "Active Energy C", this->active_energy_c_sensor_); + LOG_SENSOR(" ", "Reactive Energy A", this->reactive_energy_a_sensor_); + LOG_SENSOR(" ", "Reactive Energy B", this->reactive_energy_b_sensor_); + LOG_SENSOR(" ", "Reactive Energy C", this->reactive_energy_c_sensor_); + LOG_SENSOR(" ", "Apparent Energy A", this->apparent_energy_a_sensor_); + LOG_SENSOR(" ", "Apparent Energy B", this->apparent_energy_b_sensor_); + LOG_SENSOR(" ", "Apparent Energy C", this->apparent_energy_c_sensor_); + LOG_SENSOR(" ", "Frequency", this->frequency_sensor_); + LOG_SENSOR(" ", "Total Active Power", this->total_active_power_sensor_); + LOG_SENSOR(" ", "Total Reactive Power", this->total_reactive_power_sensor_); + LOG_SENSOR(" ", "Total Apparent Power", this->total_apparent_power_sensor_); + LOG_SENSOR(" ", "Total Power Factor", this->total_power_factor_sensor_); + LOG_SENSOR(" ", "Total Active Energy", this->total_active_energy_sensor_); + LOG_SENSOR(" ", "Total Reactive Energy", this->total_reactive_energy_sensor_); + LOG_SENSOR(" ", "Total Apparent Energy", this->total_apparent_energy_sensor_); +} + +// 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..9d8351f6f7 --- /dev/null +++ b/esphome/components/pzem6l24/pzem6l24.h @@ -0,0 +1,106 @@ +#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 { + protected: + // Ahead of the sensor pointers so the two bytes share the base class's tail padding. + // 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}; + + public: + SUB_SENSOR(voltage_a) + SUB_SENSOR(voltage_b) + SUB_SENSOR(voltage_c) + SUB_SENSOR(current_a) + SUB_SENSOR(current_b) + SUB_SENSOR(current_c) + SUB_SENSOR(active_power_a) + SUB_SENSOR(active_power_b) + SUB_SENSOR(active_power_c) + SUB_SENSOR(reactive_power_a) + SUB_SENSOR(reactive_power_b) + SUB_SENSOR(reactive_power_c) + SUB_SENSOR(apparent_power_a) + SUB_SENSOR(apparent_power_b) + SUB_SENSOR(apparent_power_c) + SUB_SENSOR(power_factor_a) + SUB_SENSOR(power_factor_b) + SUB_SENSOR(power_factor_c) + SUB_SENSOR(active_energy_a) + SUB_SENSOR(active_energy_b) + SUB_SENSOR(active_energy_c) + SUB_SENSOR(reactive_energy_a) + SUB_SENSOR(reactive_energy_b) + SUB_SENSOR(reactive_energy_c) + SUB_SENSOR(apparent_energy_a) + SUB_SENSOR(apparent_energy_b) + SUB_SENSOR(apparent_energy_c) + SUB_SENSOR(frequency) + SUB_SENSOR(total_active_power) + SUB_SENSOR(total_reactive_power) + SUB_SENSOR(total_apparent_power) + SUB_SENSOR(total_power_factor) + SUB_SENSOR(total_active_energy) + SUB_SENSOR(total_reactive_energy) + SUB_SENSOR(total_apparent_energy) + + public: + // 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_(); +}; + +} // namespace esphome::pzem6l24 diff --git a/esphome/components/pzem6l24/sensor.py b/esphome/components/pzem6l24/sensor.py new file mode 100644 index 0000000000..5e883acd7e --- /dev/null +++ b/esphome/components/pzem6l24/sensor.py @@ -0,0 +1,230 @@ +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; each needs its sensors(...) call in to_code +_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) + + sensors = sensor.sub_sensors(config) + await sensors("voltage_a", var.set_voltage_a_sensor) + await sensors("voltage_b", var.set_voltage_b_sensor) + await sensors("voltage_c", var.set_voltage_c_sensor) + await sensors("current_a", var.set_current_a_sensor) + await sensors("current_b", var.set_current_b_sensor) + await sensors("current_c", var.set_current_c_sensor) + await sensors("active_power_a", var.set_active_power_a_sensor) + await sensors("active_power_b", var.set_active_power_b_sensor) + await sensors("active_power_c", var.set_active_power_c_sensor) + await sensors("reactive_power_a", var.set_reactive_power_a_sensor) + await sensors("reactive_power_b", var.set_reactive_power_b_sensor) + await sensors("reactive_power_c", var.set_reactive_power_c_sensor) + await sensors("apparent_power_a", var.set_apparent_power_a_sensor) + await sensors("apparent_power_b", var.set_apparent_power_b_sensor) + await sensors("apparent_power_c", var.set_apparent_power_c_sensor) + await sensors("power_factor_a", var.set_power_factor_a_sensor) + await sensors("power_factor_b", var.set_power_factor_b_sensor) + await sensors("power_factor_c", var.set_power_factor_c_sensor) + await sensors("active_energy_a", var.set_active_energy_a_sensor) + await sensors("active_energy_b", var.set_active_energy_b_sensor) + await sensors("active_energy_c", var.set_active_energy_c_sensor) + await sensors("reactive_energy_a", var.set_reactive_energy_a_sensor) + await sensors("reactive_energy_b", var.set_reactive_energy_b_sensor) + await sensors("reactive_energy_c", var.set_reactive_energy_c_sensor) + await sensors("apparent_energy_a", var.set_apparent_energy_a_sensor) + await sensors("apparent_energy_b", var.set_apparent_energy_b_sensor) + await sensors("apparent_energy_c", var.set_apparent_energy_c_sensor) + await sensors(CONF_FREQUENCY, var.set_frequency_sensor) + await sensors(CONF_TOTAL_ACTIVE_POWER, var.set_total_active_power_sensor) + await sensors(CONF_TOTAL_REACTIVE_POWER, var.set_total_reactive_power_sensor) + await sensors(CONF_TOTAL_APPARENT_POWER, var.set_total_apparent_power_sensor) + await sensors(CONF_TOTAL_POWER_FACTOR, var.set_total_power_factor_sensor) + await sensors(CONF_TOTAL_ACTIVE_ENERGY, var.set_total_active_energy_sensor) + await sensors(CONF_TOTAL_REACTIVE_ENERGY, var.set_total_reactive_energy_sensor) + await sensors(CONF_TOTAL_APPARENT_ENERGY, var.set_total_apparent_energy_sensor) diff --git a/esphome/components/pzemac/__init__.py b/esphome/components/pzemac/__init__.py index e69de29bb2..600881c324 100644 --- a/esphome/components/pzemac/__init__.py +++ b/esphome/components/pzemac/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pzemac" diff --git a/esphome/components/pzemac/pzemac.cpp b/esphome/components/pzemac/pzemac.cpp index 409de91124..e291844d78 100644 --- a/esphome/components/pzemac/pzemac.cpp +++ b/esphome/components/pzemac/pzemac.cpp @@ -5,7 +5,7 @@ namespace esphome::pzemac { namespace helpers = modbus::helpers; -static const char *const TAG = "pzemac"; +ESPHOME_LOG_TAG(TAG, "pzemac"); static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 10; // 10x 16-bit registers @@ -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/__init__.py b/esphome/components/pzemdc/__init__.py index e69de29bb2..81e2bf067d 100644 --- a/esphome/components/pzemdc/__init__.py +++ b/esphome/components/pzemdc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "pzemdc" diff --git a/esphome/components/pzemdc/pzemdc.cpp b/esphome/components/pzemdc/pzemdc.cpp index eb9a355806..76b9c2b2e0 100644 --- a/esphome/components/pzemdc/pzemdc.cpp +++ b/esphome/components/pzemdc/pzemdc.cpp @@ -5,7 +5,7 @@ namespace esphome::pzemdc { namespace helpers = modbus::helpers; -static const char *const TAG = "pzemdc"; +ESPHOME_LOG_TAG(TAG, "pzemdc"); static const uint8_t PZEM_CMD_RESET_ENERGY = 0x42; static const uint8_t PZEM_REGISTER_COUNT = 8; // 8x 16-bit registers 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/__init__.py b/esphome/components/qmc5883l/__init__.py index e69de29bb2..fb646a60c3 100644 --- a/esphome/components/qmc5883l/__init__.py +++ b/esphome/components/qmc5883l/__init__.py @@ -0,0 +1 @@ +DOMAIN = "qmc5883l" diff --git a/esphome/components/qmc5883l/qmc5883l.cpp b/esphome/components/qmc5883l/qmc5883l.cpp index ba6a71f97d..dcfa46abc5 100644 --- a/esphome/components/qmc5883l/qmc5883l.cpp +++ b/esphome/components/qmc5883l/qmc5883l.cpp @@ -7,7 +7,7 @@ namespace esphome::qmc5883l { -static const char *const TAG = "qmc5883l"; +ESPHOME_LOG_TAG(TAG, "qmc5883l"); static const uint8_t QMC5883L_ADDRESS = 0x0D; 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/qmi8658/__init__.py b/esphome/components/qmi8658/__init__.py index 67838dbc3c..6c871dfc4e 100644 --- a/esphome/components/qmi8658/__init__.py +++ b/esphome/components/qmi8658/__init__.py @@ -4,6 +4,7 @@ from esphome.components.motion import MotionComponent CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["i2c", "motion"] +DOMAIN = "qmi8658" CONF_QMI8658_ID = "qmi8658_id" # C++ namespace / class diff --git a/esphome/components/qmi8658/qmi8658.cpp b/esphome/components/qmi8658/qmi8658.cpp index 2fd457d290..4f35d5bf36 100644 --- a/esphome/components/qmi8658/qmi8658.cpp +++ b/esphome/components/qmi8658/qmi8658.cpp @@ -4,7 +4,7 @@ namespace esphome::qmi8658 { -static const char *const TAG = "qmi8658"; +ESPHOME_LOG_TAG(TAG, "qmi8658"); // Acceleration scale (g per LSB), indexed by accel_range_ >> 4. // Full-scale = range_g, mapped over a signed 16-bit value (2^15 counts). diff --git a/esphome/components/qmp6988/__init__.py b/esphome/components/qmp6988/__init__.py index 09bcf51589..86498d192a 100644 --- a/esphome/components/qmp6988/__init__.py +++ b/esphome/components/qmp6988/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@andrewpc"] +DOMAIN = "qmp6988" diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 547991f75e..ea3bf19eb4 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -43,7 +43,7 @@ static const uint8_t QMP6988_CONFIG_REG_FILTER_LEN = 3; static const uint32_t SUBTRACTOR = 8388608; -static const char *const TAG = "qmp6988"; +ESPHOME_LOG_TAG(TAG, "qmp6988"); static const char *oversampling_to_str(QMP6988Oversampling oversampling) { switch (oversampling) { diff --git a/esphome/components/qr_code/__init__.py b/esphome/components/qr_code/__init__.py index 8b773dcdf5..222041837b 100644 --- a/esphome/components/qr_code/__init__.py +++ b/esphome/components/qr_code/__init__.py @@ -7,6 +7,7 @@ CONF_SCALE = "scale" CONF_ECC = "ecc" CODEOWNERS = ["@wjtje"] +DOMAIN = "qr_code" DEPENDENCIES = ["display"] MULTI_CONF = True diff --git a/esphome/components/qr_code/qr_code.cpp b/esphome/components/qr_code/qr_code.cpp index edb78b98e1..78d20d9bfe 100644 --- a/esphome/components/qr_code/qr_code.cpp +++ b/esphome/components/qr_code/qr_code.cpp @@ -5,7 +5,7 @@ namespace esphome::qr_code { -static const char *const TAG = "qr_code"; +ESPHOME_LOG_TAG(TAG, "qr_code"); void QrCode::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/qspi_amoled/display.py b/esphome/components/qspi_amoled/display.py deleted file mode 100644 index 00773b516a..0000000000 --- a/esphome/components/qspi_amoled/display.py +++ /dev/null @@ -1,3 +0,0 @@ -import esphome.config_validation as cv - -CONFIG_SCHEMA = cv.invalid("The qspi_amoled component has been renamed to qspi_dbi") diff --git a/esphome/components/qspi_dbi/__init__.py b/esphome/components/qspi_dbi/__init__.py index aebbe2fcc8..abf64f675a 100644 --- a/esphome/components/qspi_dbi/__init__.py +++ b/esphome/components/qspi_dbi/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "qspi_dbi" CONF_DRAW_FROM_ORIGIN = "draw_from_origin" diff --git a/esphome/components/qwiic_pir/__init__.py b/esphome/components/qwiic_pir/__init__.py index e69de29bb2..2232f1e670 100644 --- a/esphome/components/qwiic_pir/__init__.py +++ b/esphome/components/qwiic_pir/__init__.py @@ -0,0 +1 @@ +DOMAIN = "qwiic_pir" diff --git a/esphome/components/qwiic_pir/qwiic_pir.cpp b/esphome/components/qwiic_pir/qwiic_pir.cpp index eb338db772..fffc3514d0 100644 --- a/esphome/components/qwiic_pir/qwiic_pir.cpp +++ b/esphome/components/qwiic_pir/qwiic_pir.cpp @@ -3,7 +3,7 @@ namespace esphome::qwiic_pir { -static const char *const TAG = "qwiic_pir"; +ESPHOME_LOG_TAG(TAG, "qwiic_pir"); void QwiicPIRComponent::setup() { // Verify I2C communcation by reading and verifying the chip ID diff --git a/esphome/components/radio_frequency/__init__.py b/esphome/components/radio_frequency/__init__.py index f303a14297..cd680450d5 100644 --- a/esphome/components/radio_frequency/__init__.py +++ b/esphome/components/radio_frequency/__init__.py @@ -10,22 +10,22 @@ 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"] +DOMAIN = "radio_frequency" 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 +55,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..231d6c3f89 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 ========== +ESPHOME_LOG_TAG(TAG, "radio_frequency"); 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_ble/__init__.py b/esphome/components/radon_eye_ble/__init__.py index 0ca9f64326..e2af976399 100644 --- a/esphome/components/radon_eye_ble/__init__.py +++ b/esphome/components/radon_eye_ble/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base"] CODEOWNERS = ["@jeffeb3"] +DOMAIN = "radon_eye_ble" radon_eye_ble_ns = cg.esphome_ns.namespace("radon_eye_ble") RadonEyeListener = radon_eye_ble_ns.class_( diff --git a/esphome/components/radon_eye_ble/radon_eye_listener.cpp b/esphome/components/radon_eye_ble/radon_eye_listener.cpp index 9ff279cab9..bef5975785 100644 --- a/esphome/components/radon_eye_ble/radon_eye_listener.cpp +++ b/esphome/components/radon_eye_ble/radon_eye_listener.cpp @@ -4,7 +4,7 @@ namespace esphome::radon_eye_ble { -static const char *const TAG = "radon_eye_ble"; +ESPHOME_LOG_TAG(TAG, "radon_eye_ble"); bool RadonEyeListener::parse_device(const ble_device_base::ESPBTDevice &device) { // Radon Eye devices have names starting with "FR:" diff --git a/esphome/components/radon_eye_rd200/__init__.py b/esphome/components/radon_eye_rd200/__init__.py index 0740f6967b..a2076ef945 100644 --- a/esphome/components/radon_eye_rd200/__init__.py +++ b/esphome/components/radon_eye_rd200/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@jeffeb3"] +DOMAIN = "radon_eye_rd200" diff --git a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp index de5bd3d8d5..84b89f377b 100644 --- a/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp +++ b/esphome/components/radon_eye_rd200/radon_eye_rd200.cpp @@ -7,7 +7,7 @@ namespace esphome::radon_eye_rd200 { -static const char *const TAG = "radon_eye_rd200"; +ESPHOME_LOG_TAG(TAG, "radon_eye_rd200"); static const esp32_ble_tracker::ESPBTUUID SERVICE_UUID_V1 = esp32_ble_tracker::ESPBTUUID::from_raw("00001523-1212-efde-1523-785feabcd123"); 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/rc522/__init__.py b/esphome/components/rc522/__init__.py index e9e8dd7b73..69cc713813 100644 --- a/esphome/components/rc522/__init__.py +++ b/esphome/components/rc522/__init__.py @@ -13,6 +13,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] AUTO_LOAD = ["binary_sensor"] +DOMAIN = "rc522" CONF_RC522_ID = "rc522_id" diff --git a/esphome/components/rc522/rc522.cpp b/esphome/components/rc522/rc522.cpp index 7c1b6ae314..6549ba9846 100644 --- a/esphome/components/rc522/rc522.cpp +++ b/esphome/components/rc522/rc522.cpp @@ -9,7 +9,7 @@ namespace esphome::rc522 { static const uint8_t WAIT_I_RQ = 0x30; // RxIRq and IdleIRq -static const char *const TAG = "rc522"; +ESPHOME_LOG_TAG(TAG, "rc522"); // Max UID size for RFID tags (4, 7, or 10 bytes) static constexpr size_t RC522_MAX_UID_SIZE = 10; diff --git a/esphome/components/rc522_i2c/__init__.py b/esphome/components/rc522_i2c/__init__.py index c3fd368bbd..3fa9ef8a7b 100644 --- a/esphome/components/rc522_i2c/__init__.py +++ b/esphome/components/rc522_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["rc522"] +DOMAIN = "rc522_i2c" MULTI_CONF = True rc522_i2c_ns = cg.esphome_ns.namespace("rc522_i2c") diff --git a/esphome/components/rc522_i2c/rc522_i2c.cpp b/esphome/components/rc522_i2c/rc522_i2c.cpp index dbc86ff8e7..7fc0416030 100644 --- a/esphome/components/rc522_i2c/rc522_i2c.cpp +++ b/esphome/components/rc522_i2c/rc522_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::rc522_i2c { -static const char *const TAG = "rc522_i2c"; +ESPHOME_LOG_TAG(TAG, "rc522_i2c"); void RC522I2C::dump_config() { RC522::dump_config(); diff --git a/esphome/components/rc522_spi/__init__.py b/esphome/components/rc522_spi/__init__.py index f2820bbaee..6f517458bb 100644 --- a/esphome/components/rc522_spi/__init__.py +++ b/esphome/components/rc522_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@glmnet"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["rc522"] +DOMAIN = "rc522_spi" MULTI_CONF = True rc522_spi_ns = cg.esphome_ns.namespace("rc522_spi") diff --git a/esphome/components/rc522_spi/rc522_spi.cpp b/esphome/components/rc522_spi/rc522_spi.cpp index b63ad1cfdc..2d9bbf5b52 100644 --- a/esphome/components/rc522_spi/rc522_spi.cpp +++ b/esphome/components/rc522_spi/rc522_spi.cpp @@ -7,7 +7,7 @@ namespace esphome::rc522_spi { -static const char *const TAG = "rc522_spi"; +ESPHOME_LOG_TAG(TAG, "rc522_spi"); void RC522Spi::setup() { this->spi_setup(); diff --git a/esphome/components/rd03d/__init__.py b/esphome/components/rd03d/__init__.py index 4fff41e4f6..96bf4ffd74 100644 --- a/esphome/components/rd03d/__init__.py +++ b/esphome/components/rd03d/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jasstrong"] DEPENDENCIES = ["uart"] +DOMAIN = "rd03d" MULTI_CONF = True CONF_RD03D_ID = "rd03d_id" 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/rd03d.cpp b/esphome/components/rd03d/rd03d.cpp index 18328def9f..7a2af571c7 100644 --- a/esphome/components/rd03d/rd03d.cpp +++ b/esphome/components/rd03d/rd03d.cpp @@ -8,7 +8,7 @@ namespace esphome::rd03d { -static const char *const TAG = "rd03d"; +ESPHOME_LOG_TAG(TAG, "rd03d"); // Delay before sending configuration commands to allow radar to initialize static constexpr uint32_t SETUP_TIMEOUT_MS = 100; 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/rdm6300/__init__.py b/esphome/components/rdm6300/__init__.py index d3803bd3f7..86eb2cd738 100644 --- a/esphome/components/rdm6300/__init__.py +++ b/esphome/components/rdm6300/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] AUTO_LOAD = ["binary_sensor"] +DOMAIN = "rdm6300" MULTI_CONF = True rdm6300_ns = cg.esphome_ns.namespace("rdm6300") diff --git a/esphome/components/rdm6300/rdm6300.cpp b/esphome/components/rdm6300/rdm6300.cpp index 00c91cbc47..2bdbde9174 100644 --- a/esphome/components/rdm6300/rdm6300.cpp +++ b/esphome/components/rdm6300/rdm6300.cpp @@ -4,7 +4,7 @@ namespace esphome::rdm6300 { -static const char *const TAG = "rdm6300"; +ESPHOME_LOG_TAG(TAG, "rdm6300"); static const uint8_t RDM6300_START_BYTE = 0x02; static const uint8_t RDM6300_END_BYTE = 0x03; diff --git a/esphome/components/remko_ar715/__init__.py b/esphome/components/remko_ar715/__init__.py new file mode 100644 index 0000000000..18eb60df33 --- /dev/null +++ b/esphome/components/remko_ar715/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@naKruul"] +DOMAIN = "remko_ar715" diff --git a/esphome/components/remko_ar715/climate.py b/esphome/components/remko_ar715/climate.py new file mode 100644 index 0000000000..d640993a4c --- /dev/null +++ b/esphome/components/remko_ar715/climate.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL +import esphome.config_validation as cv +from esphome.const import CONF_SUPPORTS_HEAT +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +remko_ar715_ns = cg.esphome_ns.namespace("remko_ar715") +RemkoAr715Climate = remko_ar715_ns.class_("RemkoAr715Climate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_schema(RemkoAr715Climate).extend( + { + cv.Optional(CONF_SUPPORTS_HEAT, default=False): cv.boolean_false, + cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false, + } +) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/remko_ar715/remko_ar715.cpp b/esphome/components/remko_ar715/remko_ar715.cpp new file mode 100644 index 0000000000..f6d5a2f62c --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.cpp @@ -0,0 +1,93 @@ +#include "remko_ar715.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include + +namespace esphome::remko_ar715 { + +ESPHOME_LOG_TAG(TAG, "remko_ar715.climate"); + +// Timings from ir_ClimaButler.cpp (IRremoteESP8266), in microseconds +static constexpr uint16_t BIT_MARK = 511; +static constexpr uint16_t HDR_SPACE = 3492; +static constexpr uint16_t ONE_SPACE = 1540; +static constexpr uint16_t ZERO_SPACE = 548; +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint8_t BITS = 52; + +static constexpr uint8_t POWER_ON = 0x3; +static constexpr uint8_t POWER_OFF = 0x0; +static constexpr uint8_t SWING_ON = 0x5; +static constexpr uint8_t SWING_OFF = 0x7; + +// Mode/fan byte: high nibble N3, low nibble N2 +static constexpr uint8_t MODE_FAN_AUTO = 0x80; +static constexpr uint8_t MODE_FAN_LOW = 0xA2; +static constexpr uint8_t MODE_FAN_MEDIUM = 0x91; +static constexpr uint8_t MODE_FAN_HIGH = 0x88; +static constexpr uint8_t MODE_DRY = 0xA4; +static constexpr uint8_t MODE_FAN_ONLY = 0xA3; + +static uint8_t fan_mode_byte(climate::ClimateFanMode fan_mode) { + switch (fan_mode) { + case climate::CLIMATE_FAN_HIGH: + return MODE_FAN_HIGH; + case climate::CLIMATE_FAN_MEDIUM: + return MODE_FAN_MEDIUM; + case climate::CLIMATE_FAN_LOW: + return MODE_FAN_LOW; + default: + return MODE_FAN_AUTO; + } +} + +// 52-bit word, 13 nibbles MSB first: +// N12=0x8 (fixed), N11=power, N10..N5=0 (timer, unused), N4=swing, +// N3,N2=mode/fan, N1=temp-16, N0=checksum (0xF - sum(N12..N1)) & 0xF +static uint64_t build_frame(uint8_t power, uint8_t swing, uint8_t mode_fan, uint8_t temp) { + const uint8_t temp_nibble = (temp - 16) & 0xF; + const uint8_t sum = 0x8 + power + swing + (mode_fan >> 4) + (mode_fan & 0xF) + temp_nibble; + const uint8_t checksum = (0xF - sum) & 0xF; + return (uint64_t{0x8} << 48) | (uint64_t{power} << 44) | (uint32_t{swing} << 16) | (uint32_t{mode_fan} << 8) | + (temp_nibble << 4) | checksum; +} + +static void send_frame(remote_base::RemoteTransmitData *data, uint64_t code) { + data->set_carrier_frequency(CARRIER_FREQUENCY); + data->reserve(2 + BITS * 2 + 2); + data->item(BIT_MARK, HDR_SPACE); + for (int i = BITS - 1; i >= 0; i--) + data->item(BIT_MARK, (code >> i) & 1 ? ONE_SPACE : ZERO_SPACE); + // Stop bit and inter-frame gap + data->item(BIT_MARK, HDR_SPACE); +} + +void RemkoAr715Climate::transmit_state() { + const uint8_t temp = lroundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX)); + const bool power = this->mode != climate::CLIMATE_MODE_OFF; + const bool swing = this->swing_mode == climate::CLIMATE_SWING_VERTICAL; + + // DRY and FAN_ONLY carry no fan speed: mode and fan share the same nibbles + uint8_t mode_fan; + switch (this->mode) { + case climate::CLIMATE_MODE_DRY: + mode_fan = MODE_DRY; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + mode_fan = MODE_FAN_ONLY; + break; + default: + mode_fan = fan_mode_byte(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + break; + } + + const uint64_t code = build_frame(power ? POWER_ON : POWER_OFF, swing ? SWING_ON : SWING_OFF, mode_fan, temp); + ESP_LOGV(TAG, "Sending power=%s temp=%u swing=%s mode_fan=0x%02X code=0x%05" PRIX32 "%08" PRIX32, ONOFF(power), temp, + ONOFF(swing), mode_fan, static_cast(code >> 32), static_cast(code)); + + auto transmit = this->transmitter_->transmit(); + send_frame(transmit.get_data(), code); + transmit.perform(); +} + +} // namespace esphome::remko_ar715 diff --git a/esphome/components/remko_ar715/remko_ar715.h b/esphome/components/remko_ar715/remko_ar715.h new file mode 100644 index 0000000000..463617bbc6 --- /dev/null +++ b/esphome/components/remko_ar715/remko_ar715.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome::remko_ar715 { + +// Remko AR-715 IR remote: 52-bit pulse-distance frame, full state in every command. +// IRremoteESP8266 calls it the ClimaButler protocol after a rebrand; Remko is the manufacturer. +// Protocol analysis: https://github.com/crankyoldgit/IRremoteESP8266/issues/1812 +// +// Supported devices: +// - Remko RKL series (RKL 490, 491, 494, 495) with AR-715 remote +// - Fischer ClimaButler RCS-SD43UAI / RCS-SD43UWI +// - TROTEC PAC 4600 +// - Novamatic CL 990 / CL 1590 +// - Rexair C15000N +// - freecom RCS-SD43UAI / RCS-SD43UWI + +static constexpr float TEMP_MIN = 16.0f; +static constexpr float TEMP_MAX = 30.0f; + +class RemkoAr715Climate final : public climate_ir::ClimateIR { + public: + RemkoAr715Climate() + : climate_ir::ClimateIR(TEMP_MIN, TEMP_MAX, + 1.0f, // temperature step + true, // supports dry + true, // supports fan only + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}) {} + + protected: + void transmit_state() override; +}; + +} // namespace esphome::remko_ar715 diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index e542ba3ed8..286d209e98 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -9,6 +9,7 @@ from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( CONF_ADDRESS, + CONF_BATTERY_LEVEL, CONF_BUTTON, CONF_CARRIER_FREQUENCY, CONF_CHANNEL, @@ -20,12 +21,17 @@ from esphome.const import ( CONF_DELTA, CONF_DEVICE, CONF_FAMILY, + CONF_FAN_MODE, + CONF_FORCE_UPDATE, CONF_GROUP, + CONF_HUMIDITY, CONF_ID, CONF_INDEX, CONF_INVERTED, + CONF_KEY, CONF_LEVEL, CONF_MAGNITUDE, + CONF_MODE, CONF_NBITS, CONF_ONE, CONF_PROTOCOL, @@ -37,6 +43,7 @@ from esphome.const import ( CONF_SOURCE, CONF_STATE, CONF_SYNC, + CONF_TEMPERATURE, CONF_TIMES, CONF_TRIGGER_ID, CONF_TYPE_ID, @@ -47,15 +54,23 @@ from esphome.const import ( from esphome.core import ID, coroutine from esphome.cpp_generator import MockObj from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor -from esphome.types import ConfigType +from esphome.types import ConfigType, SafeExpType, TemplateArgsType from esphome.util import Registry, SimpleRegistry AUTO_LOAD = ["binary_sensor"] +DOMAIN = "remote_base" +CONF_ADDRESS_2 = "address_2" CONF_RECEIVER_ID = "receiver_id" CONF_TRANSMITTER_ID = "transmitter_id" CONF_FIRST = "first" +CONF_SUFFIX = "suffix" +CONF_SUFFIX_BITS = "suffix_bits" +CONF_CONTROL = "control" +CONF_CONTROL_IN_KEY = "control_in_key" +CONF_ROLLING_CODE = "rolling_code" +CONF_EXTENSION = "extension" ns = remote_base_ns = cg.esphome_ns.namespace("remote_base") RemoteProtocol = ns.class_("RemoteProtocol") @@ -136,8 +151,8 @@ async def attach_receiver( add_listener(receiver, var) -async def register_transmittable(var, config): - transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) +async def register_transmittable(var, config, key: str = CONF_TRANSMITTER_ID): + transmitter_ = await cg.get_variable(config[key]) cg.add(var.set_transmitter(transmitter_)) @@ -254,10 +269,23 @@ BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( ).extend(REMOTE_TRANSMITTABLE_SCHEMA) -def register_action(name, type_, schema): - validator = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) +def _transmit_action( + name: str, + schema: cv.Schema, + validate: Callable[[ConfigType], ConfigType] | None, +) -> tuple[str, cv.Schema, Any]: + """The action name, its schema and the schema with ``validate`` applied.""" + schema = templatize(schema).extend(BASE_REMOTE_TRANSMITTER_SCHEMA) + validator = schema if validate is None else cv.All(schema, validate) + return f"remote_transmitter.transmit_{name}", schema, validator + + +def register_action( + name, type_, schema, *, validate: Callable[[ConfigType], ConfigType] | None = None +): + action, _, validator = _transmit_action(name, schema, validate) registerer = automation.register_action( - f"remote_transmitter.transmit_{name}", + action, type_, validator, synchronous=True, @@ -282,6 +310,75 @@ def register_action(name, type_, schema): return decorator +ProtocolField = ( + tuple[str, str, SafeExpType] | automation.ApplyField | automation.ApplyCall +) + + +def _protocol_field( + field: ProtocolField, +) -> automation.ApplyField | automation.ApplyCall: + if not isinstance(field, tuple): + return field + key, member, type_ = field + return automation.ApplyField(key, f"data.{member} = {{}}", type_) + + +_BYTES = cg.std_vector.template(cg.uint8) + + +def _code_table(config: ConfigType, value: list[int]) -> str: + return f"{automation.progmem_bytes('remote_base_code', value)}, {len(value)}" + + +def _bytes_field(conf_key: str, target: str) -> automation.ApplyField: + """A constant's flash table and length, or a lambda's vector; the callee takes both.""" + return automation.ApplyField(conf_key, target, _BYTES, const_fn=_code_table) + + +def register_protocol_action( + name: str, + protocol: str, + schema: cv.Schema, + *fields: ProtocolField, + validate: Callable[[ConfigType], ConfigType] | None = None, + call: str = "protocol_call", +) -> None: + """Register ``transmit_`` with no C++ action class. + + One generated function fills ``Data`` from ``(conf_key, member, type_)`` fields + and sends it; the action is a core ``ApplyAction``. ``ApplyField`` and ``ApplyCall`` pass through + and target the call object, whose protocol data is ``data``. + ``call="encode_call"`` targets a plain transmit call whose ``encode()`` runs one of the + protocol's encoders, for payloads it encodes straight from a pointer and length. + """ + action, schema, validator = _transmit_action(name, schema, validate) + builder = automation.apply_action_builder( + action, + schema, + *(_protocol_field(field) for field in fields), + automation.ApplyField((CONF_REPEAT, CONF_TIMES), "set_send_times", cg.uint32), + automation.ApplyField( + (CONF_REPEAT, CONF_WAIT_TIME), "set_send_wait", cg.uint32 + ), + call=f"{call}<{ns.class_(f'{protocol}Protocol')}>", + id_key=CONF_TRANSMITTER_ID, + ) + + async def protocol_builder( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, + ) -> MockObj: + _request_protocol_if_in_tree(name) + return await builder(config, action_id, template_arg, args) + + automation.register_action( + action, automation.ApplyAction, validator, synchronous=True + )(protocol_builder) + + def declare_protocol(name): data = ns.struct(f"{name}Data") binary_sensor_ = ns.class_(f"{name}BinarySensor", RemoteReceiverBinarySensorBase) @@ -365,9 +462,7 @@ async def build_dumpers(config): # Beo4 -Beo4Data, Beo4BinarySensor, Beo4Trigger, Beo4Action, Beo4Dumper = declare_protocol( - "Beo4" -) +Beo4Data, Beo4BinarySensor, Beo4Trigger, _, Beo4Dumper = declare_protocol("Beo4") BEO4_SCHEMA = cv.Schema( { cv.Required(CONF_SOURCE): cv.hex_uint8_t, @@ -401,14 +496,14 @@ def beo4_dumper(var, config): pass -@register_action("beo4", Beo4Action, BEO4_SCHEMA) -async def beo4_action(var, config, args): - template_ = await cg.templatable(config[CONF_SOURCE], args, cg.uint8) - cg.add(var.set_source(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) - cg.add(var.set_repeats(template_)) +register_protocol_action( + "beo4", + "Beo4", + BEO4_SCHEMA, + (CONF_SOURCE, "source", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + (CONF_COMMAND_REPEATS, "repeats", cg.uint8), +) # Brennenstuhl @@ -416,7 +511,7 @@ async def beo4_action(var, config, args): BrennenstuhlData, BrennenstuhlBinarySensor, BrennenstuhlTrigger, - BrennenstuhlAction, + _, BrennenstuhlDumper, ) = declare_protocol("Brennenstuhl") @@ -449,10 +544,12 @@ def brennenstuhl_dumper(var, config): pass -@register_action("brennenstuhl", BrennenstuhlAction, BRENNENSTUHL_SCHEMA) -async def brennenstuhl_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_code(template_)) +register_protocol_action( + "brennenstuhl", + "Brennenstuhl", + BRENNENSTUHL_SCHEMA, + (CONF_CODE, "code", cg.uint32), +) # ByronSX @@ -460,7 +557,7 @@ async def brennenstuhl_action(var, config, args): ByronSXData, ByronSXBinarySensor, ByronSXTrigger, - ByronSXAction, + _, ByronSXDumper, ) = declare_protocol("ByronSX") BYRONSX_SCHEMA = cv.Schema( @@ -496,12 +593,13 @@ def byronsx_dumper(var, config): pass -@register_action("byronsx", ByronSXAction, BYRONSX_SCHEMA) -async def byronsx_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "byronsx", + "ByronSX", + BYRONSX_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # CanalSat @@ -509,7 +607,7 @@ async def byronsx_action(var, config, args): CanalSatData, CanalSatBinarySensor, CanalSatTrigger, - CanalSatAction, + _, CanalSatDumper, ) = declare_protocol("CanalSat") CANALSAT_SCHEMA = cv.Schema( @@ -545,21 +643,21 @@ def canalsat_dumper(var, config): pass -@register_action("canalsat", CanalSatAction, CANALSAT_SCHEMA) -async def canalsat_action(var, config, args): - template_ = await cg.templatable(config[CONF_DEVICE], args, cg.uint8) - cg.add(var.set_device(template_)) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "canalsat", + "CanalSat", + CANALSAT_SCHEMA, + (CONF_DEVICE, "device", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) ( CanalSatLDData, CanalSatLDBinarySensor, CanalSatLDTrigger, - CanalSatLDAction, + _, CanalSatLDDumper, ) = declare_protocol("CanalSatLD") CANALSATLD_SCHEMA = cv.Schema( @@ -595,14 +693,14 @@ def canalsatld_dumper(var, config): pass -@register_action("canalsatld", CanalSatLDAction, CANALSATLD_SCHEMA) -async def canalsatld_action(var, config, args): - template_ = await cg.templatable(config[CONF_DEVICE], args, cg.uint8) - cg.add(var.set_device(template_)) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "canalsatld", + "CanalSatLD", + CANALSATLD_SCHEMA, + (CONF_DEVICE, "device", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # Coolix @@ -610,7 +708,7 @@ async def canalsatld_action(var, config, args): CoolixData, CoolixBinarySensor, CoolixTrigger, - CoolixAction, + _, CoolixDumper, ) = declare_protocol("Coolix") @@ -640,12 +738,13 @@ def coolix_binary_sensor(var, config): cg.add(var.set_data(cg.ArrayInitializer(0, config))) -@register_action("coolix", CoolixAction, COOLIX_BASE_SCHEMA) -async def coolix_action(var, config, args): - template_ = await cg.templatable(config[CONF_FIRST], args, cg.uint32) - cg.add(var.set_first(template_)) - template_ = await cg.templatable(config[CONF_SECOND], args, cg.uint32) - cg.add(var.set_second(template_)) +register_protocol_action( + "coolix", + "Coolix", + COOLIX_BASE_SCHEMA, + (CONF_FIRST, "first", cg.uint32), + (CONF_SECOND, "second", cg.uint32), +) @register_trigger("coolix", CoolixTrigger, CoolixData) @@ -659,9 +758,7 @@ def coolix_dumper(var, config): # Dish -DishData, DishBinarySensor, DishTrigger, DishAction, DishDumper = declare_protocol( - "Dish" -) +DishData, DishBinarySensor, DishTrigger, _, DishDumper = declare_protocol("Dish") DISH_SCHEMA = cv.Schema( { cv.Optional(CONF_ADDRESS, default=1): cv.int_range(min=1, max=16), @@ -693,18 +790,17 @@ def dish_dumper(var, config): pass -@register_action("dish", DishAction, DISH_SCHEMA) -async def dish_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "dish", + "Dish", + DISH_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) # Dooya -DooyaData, DooyaBinarySensor, DooyaTrigger, DooyaAction, DooyaDumper = declare_protocol( - "Dooya" -) +DooyaData, DooyaBinarySensor, DooyaTrigger, _, DooyaDumper = declare_protocol("Dooya") DOOYA_SCHEMA = cv.Schema( { cv.Required(CONF_ID): cv.hex_int_range(0, 16777215), @@ -740,22 +836,19 @@ def dooya_dumper(var, config): pass -@register_action("dooya", DooyaAction, DOOYA_SCHEMA) -async def dooya_action(var, config, args): - template_ = await cg.templatable(config[CONF_ID], args, cg.uint32) - cg.add(var.set_id(template_)) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - template_ = await cg.templatable(config[CONF_BUTTON], args, cg.uint8) - cg.add(var.set_button(template_)) - template_ = await cg.templatable(config[CONF_CHECK], args, cg.uint8) - cg.add(var.set_check(template_)) +register_protocol_action( + "dooya", + "Dooya", + DOOYA_SCHEMA, + (CONF_ID, "id", cg.uint32), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_BUTTON, "button", cg.uint8), + (CONF_CHECK, "check", cg.uint8), +) # Dyson -DysonData, DysonBinarySensor, DysonTrigger, DysonAction, DysonDumper = declare_protocol( - "Dyson" -) +DysonData, DysonBinarySensor, DysonTrigger, _, DysonDumper = declare_protocol("Dyson") DYSON_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.hex_uint16_t, @@ -787,16 +880,17 @@ def dyson_dumper(var, config): pass -@register_action("dyson", DysonAction, DYSON_SCHEMA) -async def dyson_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint16) - cg.add(var.set_code(template_)) - template_ = await cg.templatable(config[CONF_INDEX], args, cg.uint8) - cg.add(var.set_index(template_)) +register_protocol_action( + "dyson", + "Dyson", + DYSON_SCHEMA, + (CONF_CODE, "code", cg.uint16), + (CONF_INDEX, "index", cg.uint8), +) # JVC -JVCData, JVCBinarySensor, JVCTrigger, JVCAction, JVCDumper = declare_protocol("JVC") +JVCData, JVCBinarySensor, JVCTrigger, _, JVCDumper = declare_protocol("JVC") JVC_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint32_t}) @@ -822,14 +916,16 @@ def jvc_dumper(var, config): pass -@register_action("jvc", JVCAction, JVC_SCHEMA) -async def jvc_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) +register_protocol_action( + "jvc", + "JVC", + JVC_SCHEMA, + (CONF_DATA, "data", cg.uint32), +) # LG -LGData, LGBinarySensor, LGTrigger, LGAction, LGDumper = declare_protocol("LG") +LGData, LGBinarySensor, LGTrigger, _, LGDumper = declare_protocol("LG") LG_SCHEMA = cv.Schema( { cv.Required(CONF_DATA): cv.hex_uint32_t, @@ -861,12 +957,13 @@ def lg_dumper(var, config): pass -@register_action("lg", LGAction, LG_SCHEMA) -async def lg_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "lg", + "LG", + LG_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), +) # MagiQuest @@ -874,7 +971,7 @@ async def lg_action(var, config, args): MagiQuestData, MagiQuestBinarySensor, MagiQuestTrigger, - MagiQuestAction, + _, MagiQuestDumper, ) = declare_protocol("MagiQuest") @@ -885,16 +982,22 @@ MAGIQUEST_SCHEMA = cv.Schema( } ) +# A wand_id of 0 matches any wand. Magnitude is still accepted but not matched on. +MAGIQUEST_BINARY_SENSOR_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WAND_ID, default=0): cv.hex_uint32_t, + cv.Optional(CONF_MAGNITUDE): cv.hex_uint16_t, + } +) -@register_binary_sensor("magiquest", MagiQuestBinarySensor, MAGIQUEST_SCHEMA) + +@register_binary_sensor( + "magiquest", MagiQuestBinarySensor, MAGIQUEST_BINARY_SENSOR_SCHEMA +) def magiquest_binary_sensor(var, config): cg.add( var.set_data( - cg.StructInitializer( - MagiQuestData, - ("magnitude", config[CONF_MAGNITUDE]), - ("wand_id", config[CONF_WAND_ID]), - ) + cg.StructInitializer(MagiQuestData, ("wand_id", config[CONF_WAND_ID])) ) ) @@ -909,12 +1012,13 @@ def magiquest_dumper(var, config): pass -@register_action("magiquest", MagiQuestAction, MAGIQUEST_SCHEMA) -async def magiquest_action(var, config, args): - template_ = await cg.templatable(config[CONF_WAND_ID], args, cg.uint32) - cg.add(var.set_wand_id(template_)) - template_ = await cg.templatable(config[CONF_MAGNITUDE], args, cg.uint16) - cg.add(var.set_magnitude(template_)) +register_protocol_action( + "magiquest", + "MagiQuest", + MAGIQUEST_SCHEMA, + (CONF_WAND_ID, "wand_id", cg.uint32), + (CONF_MAGNITUDE, "magnitude", cg.uint16), +) # Microchip HCS301 KeeLoq OOK @@ -922,7 +1026,7 @@ async def magiquest_action(var, config, args): KeeloqData, KeeloqBinarySensor, KeeloqTrigger, - KeeloqAction, + _, KeeloqDumper, ) = declare_protocol("Keeloq") KEELOQ_SCHEMA = cv.Schema( @@ -938,6 +1042,26 @@ KEELOQ_SCHEMA = cv.Schema( ) +def _keeloq_suffix_fits(config: ConfigType) -> ConfigType: + # A lambda is only known on the device. A plain value has to fit the width. + suffix = config.get(CONF_SUFFIX, 0) + bits = config.get(CONF_SUFFIX_BITS, 0) + if isinstance(suffix, int) and isinstance(bits, int) and suffix >> bits: + raise cv.Invalid( + f"suffix 0x{suffix:X} does not fit in {bits} suffix_bits", [CONF_SUFFIX] + ) + return config + + +KEELOQ_ACTION_SCHEMA = KEELOQ_SCHEMA.extend( + { + # No defaults: an absent key leaves the data at 0 and emits nothing. + cv.Optional(CONF_SUFFIX): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), + cv.Optional(CONF_SUFFIX_BITS): cv.int_range(min=0, max=16), + } +) + + @register_binary_sensor("keeloq", KeeloqBinarySensor, KEELOQ_SCHEMA) def Keeloq_binary_sensor(var, config): cg.add( @@ -961,25 +1085,28 @@ def keeloq_dumper(var, config): pass -@register_action("keeloq", KeeloqAction, KEELOQ_SCHEMA) -async def keeloq_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint32) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint32) - cg.add(var.set_encrypted(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_LEVEL], args, cg.bool_) - cg.add(var.set_vlow(template_)) +register_protocol_action( + "keeloq", + "Keeloq", + KEELOQ_ACTION_SCHEMA, + (CONF_ADDRESS, "address", cg.uint32), + (CONF_CODE, "encrypted", cg.uint32), + (CONF_COMMAND, "command", cg.uint8), + (CONF_LEVEL, "vlow", cg.bool_), + (CONF_SUFFIX, "suffix", cg.uint16), + (CONF_SUFFIX_BITS, "suffix_bits", cg.uint8), + validate=_keeloq_suffix_fits, +) # NEC -NECData, NECBinarySensor, NECTrigger, NECAction, NECDumper = declare_protocol("NEC") +NECData, NECBinarySensor, NECTrigger, _, NECDumper = declare_protocol("NEC") NEC_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, cv.Required(CONF_COMMAND): cv.hex_uint16_t, - cv.Optional(CONF_COMMAND_REPEATS, default=1): cv.uint16_t, + # Non-RMT transmitters hold an interrupt lock for the whole send, ~108 ms per extra repeat + cv.Optional(CONF_COMMAND_REPEATS, default=1): cv.int_range(min=1, max=10), } ) @@ -1008,14 +1135,51 @@ def nec_dumper(var, config): pass -@register_action("nec", NECAction, NEC_SCHEMA) -async def nec_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint16) - cg.add(var.set_command(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint16) - cg.add(var.set_command_repeats(template_)) +register_protocol_action( + "nec", + "NEC", + NEC_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint16), + (CONF_COMMAND_REPEATS, "command_repeats", cg.uint16), +) + + +# Onkyo RI +OnkyoRIData, OnkyoRIBinarySensor, OnkyoRITrigger, _, OnkyoRIDumper = declare_protocol( + "OnkyoRI" +) +ONKYORI_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_int_range(0, 0xFFF)}) + + +@register_binary_sensor("onkyori", OnkyoRIBinarySensor, ONKYORI_SCHEMA) +def onkyori_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + OnkyoRIData, + ("data", config[CONF_DATA]), + ) + ) + ) + + +@register_trigger("onkyori", OnkyoRITrigger, OnkyoRIData) +def onkyori_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("onkyori", OnkyoRIDumper) +def onkyori_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "onkyori", + "OnkyoRI", + ONKYORI_SCHEMA, + (CONF_DATA, "data", cg.uint16), +) # Pioneer @@ -1023,7 +1187,7 @@ async def nec_action(var, config, args): PioneerData, PioneerBinarySensor, PioneerTrigger, - PioneerAction, + _, PioneerDumper, ) = declare_protocol("Pioneer") PIONEER_SCHEMA = cv.Schema( @@ -1057,12 +1221,31 @@ def pioneer_dumper(var, config): pass -@register_action("pioneer", PioneerAction, PIONEER_SCHEMA) -async def pioneer_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint16) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint16) - cg.add(var.set_rc_code_2(template_)) +register_protocol_action( + "pioneer", + "Pioneer", + PIONEER_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint16), + (CONF_RC_CODE_2, "rc_code_2", cg.uint16), +) + + +# PioneerWyt +PioneerWytData = ns.class_("PioneerWytData") +PIONEER_WYT_SCHEMA = cv.Schema( + { + # 13 bytes get the checksum appended; a 14th byte is replaced by the recomputed checksum + cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=13, max=14)), + } +) + +register_protocol_action( + "pioneer_wyt", + "PioneerWyt", + PIONEER_WYT_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PioneerWytData}({{}})"), + automation.ApplyCall("data.finalize()"), +) # Pronto @@ -1070,7 +1253,7 @@ async def pioneer_action(var, config, args): ProntoData, ProntoBinarySensor, ProntoTrigger, - ProntoAction, + _, ProntoDumper, ) = declare_protocol("Pronto") PRONTO_SCHEMA = cv.Schema( @@ -1104,10 +1287,10 @@ def pronto_dumper(var, config): pass -@register_action("pronto", ProntoAction, PRONTO_SCHEMA) -async def pronto_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.std_string) - cg.add(var.set_data(template_)) +# delta only matters when matching a received code +register_protocol_action( + "pronto", "Pronto", PRONTO_SCHEMA, (CONF_DATA, "data", cg.std_string) +) # Gobox @@ -1115,7 +1298,7 @@ async def pronto_action(var, config, args): GoboxData, GoboxBinarySensor, GoboxTrigger, - GoboxAction, + _, GoboxDumper, ) = declare_protocol("Gobox") GOBOX_SCHEMA = cv.Schema( @@ -1147,10 +1330,12 @@ def gobox_dumper(var, config): pass -@register_action("gobox", GoboxAction, GOBOX_SCHEMA) -async def gobox_action(var, config, args): - template_ = await cg.templatable(config[CONF_CODE], args, cg.uint64) - cg.add(var.set_code(template_)) +register_protocol_action( + "gobox", + "Gobox", + GOBOX_SCHEMA, + (CONF_CODE, "code", cg.uint64), +) # Roomba @@ -1158,7 +1343,7 @@ async def gobox_action(var, config, args): RoombaData, RoombaBinarySensor, RoombaTrigger, - RoombaAction, + _, RoombaDumper, ) = declare_protocol("Roomba") ROOMBA_SCHEMA = cv.Schema({cv.Required(CONF_DATA): cv.hex_uint8_t}) @@ -1186,16 +1371,16 @@ def roomba_dumper(var, config): pass -@register_action("roomba", RoombaAction, ROOMBA_SCHEMA) -async def roomba_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint8) - cg.add(var.set_data(template_)) +register_protocol_action( + "roomba", + "Roomba", + ROOMBA_SCHEMA, + (CONF_DATA, "data", cg.uint8), +) # Sony -SonyData, SonyBinarySensor, SonyTrigger, SonyAction, SonyDumper = declare_protocol( - "Sony" -) +SonyData, SonyBinarySensor, SonyTrigger, _, SonyDumper = declare_protocol("Sony") SONY_SCHEMA = cv.Schema( { cv.Required(CONF_DATA): cv.hex_uint32_t, @@ -1227,16 +1412,17 @@ def sony_dumper(var, config): pass -@register_action("sony", SonyAction, SONY_SCHEMA) -async def sony_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "sony", + "Sony", + SONY_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), +) # Symphony -SymphonyData, SymphonyBinarySensor, SymphonyTrigger, SymphonyAction, SymphonyDumper = ( +SymphonyData, SymphonyBinarySensor, SymphonyTrigger, _, SymphonyDumper = ( declare_protocol("Symphony") ) SYMPHONY_SCHEMA = cv.Schema( @@ -1271,14 +1457,14 @@ def symphony_dumper(var, config): pass -@register_action("symphony", SymphonyAction, SYMPHONY_SCHEMA) -async def symphony_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint32) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) - template_ = await cg.templatable(config[CONF_COMMAND_REPEATS], args, cg.uint8) - cg.add(var.set_repeats(template_)) +register_protocol_action( + "symphony", + "Symphony", + SYMPHONY_SCHEMA, + (CONF_DATA, "data", cg.uint32), + (CONF_NBITS, "nbits", cg.uint8), + (CONF_COMMAND_REPEATS, "repeats", cg.uint8), +) # Raw @@ -1357,27 +1543,39 @@ async def raw_action(var, config, args): DraytonData, DraytonBinarySensor, DraytonTrigger, - DraytonAction, + _, DraytonDumper, ) = declare_protocol("Drayton") DRAYTON_SCHEMA = cv.Schema( { - cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0xFFFF)), - cv.Required(CONF_CHANNEL): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)), - cv.Required(CONF_COMMAND): cv.All(cv.hex_int, cv.Range(min=0, max=0x7F)), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_DATA): cv.hex_int_range(min=0, max=0xF), + cv.Optional(CONF_CHANNEL): cv.hex_int_range(min=0, max=0x1F), + cv.Optional(CONF_COMMAND): cv.hex_int_range(min=0, max=0x7F), } ) +# The legacy command is the 4 data bits followed by this many checksum bits +DRAYTON_NBITS_CHECKSUM_IN_COMMAND = 3 +# Either `data`, or the legacy `channel` + `command` pair (raw packet bits) +_validate_drayton_form = cv.All( + cv.has_none_or_all_keys(CONF_CHANNEL, CONF_COMMAND), + cv.has_exactly_one_key(CONF_DATA, CONF_COMMAND), +) -@register_binary_sensor("drayton", DraytonBinarySensor, DRAYTON_SCHEMA) +@register_binary_sensor( + "drayton", DraytonBinarySensor, cv.All(DRAYTON_SCHEMA, _validate_drayton_form) +) def drayton_binary_sensor(var, config): + # Matching compares address and data only + if (command := config.get(CONF_COMMAND)) is not None: + data = command >> DRAYTON_NBITS_CHECKSUM_IN_COMMAND + else: + data = config[CONF_DATA] cg.add( var.set_data( cg.StructInitializer( - DraytonData, - ("address", config[CONF_ADDRESS]), - ("channel", config[CONF_CHANNEL]), - ("command", config[CONF_COMMAND]), + DraytonData, ("address", config[CONF_ADDRESS]), ("data", data) ) ) ) @@ -1393,18 +1591,20 @@ def drayton_dumper(var, config): pass -@register_action("drayton", DraytonAction, DRAYTON_SCHEMA) -async def drayton_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_CHANNEL], args, cg.uint8) - cg.add(var.set_channel(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "drayton", + "Drayton", + DRAYTON_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_DATA, "data", cg.uint8), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + validate=_validate_drayton_form, +) # RC5 -RC5Data, RC5BinarySensor, RC5Trigger, RC5Action, RC5Dumper = declare_protocol("RC5") +RC5Data, RC5BinarySensor, RC5Trigger, _, RC5Dumper = declare_protocol("RC5") RC5_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.All(cv.hex_int, cv.Range(min=0, max=0x1F)), @@ -1436,16 +1636,58 @@ def rc5_dumper(var, config): pass -@register_action("rc5", RC5Action, RC5_SCHEMA) -async def rc5_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "rc5", + "RC5", + RC5_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), +) + + +# RC5X (aka RC5 Marantz) +RC5XData, RC5XBinarySensor, RC5XTrigger, _, RC5XDumper = declare_protocol("RC5X") +RC5X_SCHEMA = RC5_SCHEMA.extend( + {cv.Required(CONF_EXTENSION): cv.hex_int_range(min=0, max=0x3F)} +) + + +@register_binary_sensor("rc5x", RC5XBinarySensor, RC5X_SCHEMA) +def rc5x_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + RC5XData, + ("address", config[CONF_ADDRESS]), + ("command", config[CONF_COMMAND]), + ("extension", config[CONF_EXTENSION]), + ) + ) + ) + + +@register_trigger("rc5x", RC5XTrigger, RC5XData) +def rc5x_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("rc5x", RC5XDumper) +def rc5x_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "rc5x", + "RC5X", + RC5X_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + (CONF_EXTENSION, "extension", cg.uint8), +) # RC6 -RC6Data, RC6BinarySensor, RC6Trigger, RC6Action, RC6Dumper = declare_protocol("RC6") +RC6Data, RC6BinarySensor, RC6Trigger, _, RC6Dumper = declare_protocol("RC6") RC6_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint8_t, @@ -1479,12 +1721,17 @@ def rc6_dumper(var, config): pass -@register_action("rc6", RC6Action, RC6_SCHEMA) -async def rc6_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) +register_protocol_action( + "rc6", + "RC6", + RC6_SCHEMA, + (CONF_ADDRESS, "address", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + # Each action flips its own toggle bit, starting at 0: the lambda's static is per action + automation.ApplyCall( + "data.toggle = []() {{ static uint8_t toggle; return toggle++; }}()" + ), +) # RC Switch Raw @@ -1538,9 +1785,7 @@ def validate_rc_switch_raw_code(value): return value -def build_rc_switch_protocol(config): - if isinstance(config, int): - return rc_switch_protocol(config) +def build_custom_rc_switch_protocol(config: ConfigType) -> MockObj: pl = config[CONF_PULSE_LENGTH] return RCSwitchBase( config[CONF_SYNC][0] * pl, @@ -1553,6 +1798,24 @@ def build_rc_switch_protocol(config): ) +def rc_switch_protocol_in_flash(config: int | ConfigType) -> MockObj: + """Pointer to the protocol in flash: a built-in table entry or a shared custom table.""" + if isinstance(config, int): + return cg.RawExpression(f"&{RC_SWITCH_PROTOCOLS}[{config}]") + return cg.shared_progmem_array( + "rc_switch_custom_protocol", + RCSwitchBase, + [build_custom_rc_switch_protocol(config)], + ) + + +def rc_switch_protocol_value(config: int | ConfigType) -> MockObj: + """RAM copy of a constant protocol for the transmit actions, read from its flash table.""" + if isinstance(config, int): + return rc_switch_protocol(config) + return rc_switch_protocol_copy(rc_switch_protocol_in_flash(config)) + + RC_SWITCH_RAW_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): validate_rc_switch_raw_code, @@ -1628,6 +1891,8 @@ RC_SWITCH_TRANSMITTER = cv.Schema( ) rc_switch_protocol = ns.rc_switch_protocol +rc_switch_protocol_copy = ns.rc_switch_protocol_copy +RC_SWITCH_PROTOCOLS = ns.RC_SWITCH_PROTOCOLS RCSwitchData = ns.struct("RCSwitchData") RCSwitchBase = ns.class_("RCSwitchBase") RCSwitchTrigger = ns.class_("RCSwitchTrigger", RemoteReceiverTrigger) @@ -1642,7 +1907,7 @@ RCSwitchRawReceiver = ns.class_("RCSwitchRawReceiver", RemoteReceiverBinarySenso @register_binary_sensor("rc_switch_raw", RCSwitchRawReceiver, RC_SWITCH_RAW_SCHEMA) def rc_switch_raw_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_code(config[CONF_CODE])) @@ -1653,7 +1918,7 @@ def rc_switch_raw_binary_sensor(var, config): ) async def rc_switch_raw_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_code(await cg.templatable(config[CONF_CODE], args, cg.std_string))) @@ -1663,7 +1928,7 @@ async def rc_switch_raw_action(var, config, args): "rc_switch_type_a", RCSwitchRawReceiver, RC_SWITCH_TYPE_A_SCHEMA ) def rc_switch_type_a_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_type_a(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) @@ -1674,7 +1939,7 @@ def rc_switch_type_a_binary_sensor(var, config): ) async def rc_switch_type_a_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.std_string))) @@ -1688,7 +1953,7 @@ async def rc_switch_type_a_action(var, config, args): "rc_switch_type_b", RCSwitchRawReceiver, RC_SWITCH_TYPE_B_SCHEMA ) def rc_switch_type_b_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add( var.set_type_b(config[CONF_ADDRESS], config[CONF_CHANNEL], config[CONF_STATE]) ) @@ -1701,7 +1966,7 @@ def rc_switch_type_b_binary_sensor(var, config): ) async def rc_switch_type_b_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add(var.set_address(await cg.templatable(config[CONF_ADDRESS], args, cg.uint8))) @@ -1713,7 +1978,7 @@ async def rc_switch_type_b_action(var, config, args): "rc_switch_type_c", RCSwitchRawReceiver, RC_SWITCH_TYPE_C_SCHEMA ) def rc_switch_type_c_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add( var.set_type_c( config[CONF_FAMILY], @@ -1731,7 +1996,7 @@ def rc_switch_type_c_binary_sensor(var, config): ) async def rc_switch_type_c_action(var, config, args): proto = await cg.templatable( - config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=build_rc_switch_protocol + config[CONF_PROTOCOL], args, RCSwitchBase, to_exp=rc_switch_protocol_value ) cg.add(var.set_protocol(proto)) cg.add( @@ -1748,7 +2013,7 @@ async def rc_switch_type_c_action(var, config, args): RC_SWITCH_TYPE_D_SCHEMA.extend(RC_SWITCH_TRANSMITTER), ) def rc_switch_type_d_binary_sensor(var, config): - cg.add(var.set_protocol(build_rc_switch_protocol(config[CONF_PROTOCOL]))) + cg.add(var.set_protocol(rc_switch_protocol_in_flash(config[CONF_PROTOCOL]))) cg.add(var.set_type_d(config[CONF_GROUP], config[CONF_DEVICE], config[CONF_STATE])) @@ -1759,7 +2024,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))) @@ -1782,7 +2047,7 @@ def rc_switch_dumper(var, config): SamsungData, SamsungBinarySensor, SamsungTrigger, - SamsungAction, + _, SamsungDumper, ) = declare_protocol("Samsung") SAMSUNG_SCHEMA = cv.Schema( @@ -1816,12 +2081,13 @@ def samsung_dumper(var, config): pass -@register_action("samsung", SamsungAction, SAMSUNG_SCHEMA) -async def samsung_action(var, config, args): - template_ = await cg.templatable(config[CONF_DATA], args, cg.uint64) - cg.add(var.set_data(template_)) - template_ = await cg.templatable(config[CONF_NBITS], args, cg.uint8) - cg.add(var.set_nbits(template_)) +register_protocol_action( + "samsung", + "Samsung", + SAMSUNG_SCHEMA, + (CONF_DATA, "data", cg.uint64), + (CONF_NBITS, "nbits", cg.uint8), +) # Samsung36 @@ -1829,7 +2095,7 @@ async def samsung_action(var, config, args): Samsung36Data, Samsung36BinarySensor, Samsung36Trigger, - Samsung36Action, + _, Samsung36Dumper, ) = declare_protocol("Samsung36") SAMSUNG36_SCHEMA = cv.Schema( @@ -1863,12 +2129,13 @@ def samsung36_dumper(var, config): pass -@register_action("samsung36", Samsung36Action, SAMSUNG36_SCHEMA) -async def samsung36_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint32) - cg.add(var.set_command(template_)) +register_protocol_action( + "samsung36", + "Samsung36", + SAMSUNG36_SCHEMA, + (CONF_ADDRESS, "address", cg.uint16), + (CONF_COMMAND, "command", cg.uint32), +) # Toshiba AC @@ -1876,7 +2143,7 @@ async def samsung36_action(var, config, args): ToshibaAcData, ToshibaAcBinarySensor, ToshibaAcTrigger, - ToshibaAcAction, + _, ToshibaAcDumper, ) = declare_protocol("ToshibaAc") TOSHIBAAC_SCHEMA = cv.Schema( @@ -1910,12 +2177,13 @@ def toshibaac_dumper(var, config): pass -@register_action("toshiba_ac", ToshibaAcAction, TOSHIBAAC_SCHEMA) -async def toshibaac_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint64) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint64) - cg.add(var.set_rc_code_2(template_)) +register_protocol_action( + "toshiba_ac", + "ToshibaAc", + TOSHIBAAC_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint64), + (CONF_RC_CODE_2, "rc_code_2", cg.uint64), +) # Panasonic @@ -1923,52 +2191,119 @@ async def toshibaac_action(var, config, args): PanasonicData, PanasonicBinarySensor, PanasonicTrigger, - PanasonicAction, + _, PanasonicDumper, ) = declare_protocol("Panasonic") PANASONIC_SCHEMA = cv.Schema( { + cv.Optional(CONF_NBITS, default=48): cv.one_of(48, 56, int=True), cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Optional(CONF_ADDRESS_2, default=0x00): cv.hex_uint8_t, cv.Required(CONF_COMMAND): cv.hex_uint32_t, } ) -@register_binary_sensor("panasonic", PanasonicBinarySensor, PANASONIC_SCHEMA) -def panasonic_binary_sensor(var, config): +def _panasonic_address_2_needs_56_bits(config: ConfigType) -> ConfigType: + # A 48-bit code has no second address byte; a lambda is only known on the device + address_2 = config[CONF_ADDRESS_2] + if config[CONF_NBITS] == 48 and isinstance(address_2, int) and address_2 != 0: + raise cv.Invalid( + f"{CONF_ADDRESS_2} is only sent with {CONF_NBITS}: 56", [CONF_ADDRESS_2] + ) + return config + + +# Transmit only +PANASONIC_ACTION_SCHEMA = PANASONIC_SCHEMA.extend( + { + cv.Optional(CONF_CARRIER_FREQUENCY, default="35000Hz"): cv.All( + cv.frequency, cv.uint32_t + ), + } +) + + +@register_binary_sensor( + "panasonic", + PanasonicBinarySensor, + cv.All(PANASONIC_SCHEMA, _panasonic_address_2_needs_56_bits), +) +def panasonic_binary_sensor(var: MockObj, config: ConfigType) -> None: cg.add( var.set_data( cg.StructInitializer( PanasonicData, ("address", config[CONF_ADDRESS]), ("command", config[CONF_COMMAND]), + ("nbits", config[CONF_NBITS]), + ("address_2", config[CONF_ADDRESS_2]), ) ) ) @register_trigger("panasonic", PanasonicTrigger, PanasonicData) -def panasonic_trigger(var, config): - pass +def panasonic_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" @register_dumper("panasonic", PanasonicDumper) -def panasonic_dumper(var, config): - pass +def panasonic_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" -@register_action("panasonic", PanasonicAction, PANASONIC_SCHEMA) -async def panasonic_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint32) - cg.add(var.set_command(template_)) +register_protocol_action( + "panasonic", + "Panasonic", + PANASONIC_ACTION_SCHEMA, + (CONF_NBITS, "nbits", cg.uint8), + (CONF_ADDRESS, "address", cg.uint16), + (CONF_ADDRESS_2, "address_2", cg.uint8), + (CONF_COMMAND, "command", cg.uint32), + (CONF_CARRIER_FREQUENCY, "carrier_frequency", cg.uint32), + validate=_panasonic_address_2_needs_56_bits, +) +# SomfyRts +SOMFY_RTS_SCHEMA = cv.Schema( + { + cv.Required(CONF_KEY): cv.hex_uint8_t, + cv.Required(CONF_CONTROL): cv.All(cv.hex_uint8_t, cv.Range(max=0x0F)), + cv.Required(CONF_ROLLING_CODE): cv.hex_uint16_t, + cv.Required(CONF_ADDRESS): cv.All(cv.hex_uint32_t, cv.Range(max=0xFFFFFF)), + cv.Optional(CONF_CONTROL_IN_KEY, default=False): cv.boolean, + } +) + + +def _validate_somfy_rts(config: ConfigType) -> ConfigType: + key = config[CONF_KEY] + if config[CONF_CONTROL_IN_KEY] is True and isinstance(key, int) and key & 0x0F: + raise cv.Invalid( + f"With {CONF_CONTROL_IN_KEY} the low nibble of {CONF_KEY} carries {CONF_CONTROL} " + "and must be 0", + path=[CONF_KEY], + ) + return config + + +# Transmit only: the protocol has no decoder yet, so no binary sensor, trigger or dumper. +register_protocol_action( + "somfy_rts", + "SomfyRts", + SOMFY_RTS_SCHEMA, + (CONF_KEY, "key", cg.uint8), + (CONF_CONTROL, "control", cg.uint8), + (CONF_ROLLING_CODE, "rolling_code", cg.uint16), + (CONF_ADDRESS, "address", cg.uint32), + (CONF_CONTROL_IN_KEY, "control_in_key", cg.bool_), + validate=_validate_somfy_rts, +) + # Nexa -NexaData, NexaBinarySensor, NexaTrigger, NexaAction, NexaDumper = declare_protocol( - "Nexa" -) +NexaData, NexaBinarySensor, NexaTrigger, _, NexaDumper = declare_protocol("Nexa") NEXA_SCHEMA = cv.Schema( { cv.Required(CONF_DEVICE): cv.hex_uint32_t, @@ -2006,20 +2341,132 @@ def nexa_dumper(var, config): pass -@register_action("nexa", NexaAction, NEXA_SCHEMA) -async def nexa_action(var, config, args): - cg.add(var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.uint32))) - cg.add(var.set_group(await cg.templatable(config[CONF_GROUP], args, cg.uint8))) - cg.add(var.set_state(await cg.templatable(config[CONF_STATE], args, cg.uint8))) - cg.add(var.set_channel(await cg.templatable(config[CONF_CHANNEL], args, cg.uint8))) - cg.add(var.set_level(await cg.templatable(config[CONF_LEVEL], args, cg.uint8))) +register_protocol_action( + "nexa", + "Nexa", + NEXA_SCHEMA, + (CONF_DEVICE, "device", cg.uint32), + (CONF_GROUP, "group", cg.uint8), + (CONF_STATE, "state", cg.uint8), + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_LEVEL, "level", cg.uint8), +) + + +# Nexus +NexusData, NexusBinarySensor, NexusTrigger, _, NexusDumper = declare_protocol("Nexus") +NEXUS_SCHEMA = cv.Schema( + { + cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4), + cv.Required(CONF_ADDRESS): cv.hex_uint8_t, + } +) +NEXUS_ACTION_SCHEMA = NEXUS_SCHEMA.extend( + { + cv.Required(CONF_TEMPERATURE): cv.All( + cv.float_, cv.Range(min=-204.8, max=204.7) + ), + cv.Required(CONF_HUMIDITY): cv.uint8_t, + cv.Optional(CONF_BATTERY_LEVEL, default=True): cv.boolean, + cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean, + } +) + + +@register_binary_sensor("nexus", NexusBinarySensor, NEXUS_SCHEMA) +def nexus_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + NexusData, + ("address", config[CONF_ADDRESS]), + ("channel", config[CONF_CHANNEL]), + ) + ) + ) + + +@register_trigger("nexus", NexusTrigger, NexusData) +def nexus_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("nexus", NexusDumper) +def nexus_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "nexus", + "Nexus", + NEXUS_ACTION_SCHEMA, + (CONF_CHANNEL, "channel", cg.uint8), + (CONF_ADDRESS, "address", cg.uint8), + (CONF_TEMPERATURE, "temperature", cg.float_), + (CONF_HUMIDITY, "humidity", cg.uint8), + (CONF_BATTERY_LEVEL, "battery_level", cg.bool_), + (CONF_FORCE_UPDATE, "force_update", cg.bool_), +) + + +# PD Pioneer +( + PDPioneerData, + PDPioneerBinarySensor, + PDPioneerTrigger, + _, + PDPioneerDumper, +) = declare_protocol("PDPioneer") + + +def _validate_pd_pioneer_burst(code: list[int]) -> list[int]: + """Byte 3 tells the odd (0x02) and even (0x01) bursts apart; decode() drops anything else.""" + if code[3] not in (0x01, 0x02): + raise cv.Invalid("Byte 3 of a pd_pioneer code must be 0x01 or 0x02") + return code + + +PDPIONEER_SCHEMA = cv.Schema( + { + cv.Required(CONF_CODE): cv.All( + [cv.hex_uint8_t], cv.Length(min=13, max=14), _validate_pd_pioneer_burst + ), + } +) + + +def _pd_pioneer_finalized(code: list[int]) -> list[int]: + """Fill in the checksum: the sum of the first 13 bytes, plus 15 for an odd burst.""" + checksum = sum(code[:13], 15 if code[3] == 0x02 else 0) & 0xFF + return [*code[:13], checksum] + + +@register_binary_sensor("pd_pioneer", PDPioneerBinarySensor, PDPIONEER_SCHEMA) +def pd_pioneer_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add(var.set_data(_pd_pioneer_finalized(config[CONF_CODE]))) + + +@register_trigger("pd_pioneer", PDPioneerTrigger, PDPioneerData) +def pd_pioneer_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("pd_pioneer", PDPioneerDumper) +def pd_pioneer_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "pd_pioneer", + "PDPioneer", + PDPIONEER_SCHEMA, + _bytes_field(CONF_CODE, f"data = {PDPioneerData}({{}})"), + automation.ApplyCall("data.finalize()"), +) # Midea -MideaData, MideaBinarySensor, MideaTrigger, MideaAction, MideaDumper = declare_protocol( - "Midea" -) -MideaAction = ns.class_("MideaAction", RemoteTransmitterActionBase) +MideaData, MideaBinarySensor, MideaTrigger, _, MideaDumper = declare_protocol("Midea") MIDEA_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.All([cv.hex_uint8_t], cv.Length(min=5, max=5)), @@ -2042,17 +2489,17 @@ def midea_dumper(var, config): pass -@register_action("midea", MideaAction, MIDEA_SCHEMA) -async def midea_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) +register_protocol_action( + "midea", + "Midea", + MIDEA_SCHEMA, + _bytes_field(CONF_CODE, f"data = {MideaData}({{}})"), + automation.ApplyCall("data.finalize()"), +) # AEHA -AEHAData, AEHABinarySensor, AEHATrigger, AEHAAction, AEHADumper = declare_protocol( - "AEHA" -) +AEHAData, AEHABinarySensor, AEHATrigger, _, AEHADumper = declare_protocol("AEHA") AEHA_SCHEMA = cv.Schema( { cv.Required(CONF_ADDRESS): cv.hex_uint16_t, @@ -2084,9 +2531,9 @@ def aeha_dumper(var, config): pass -@register_action( +register_protocol_action( "aeha", - AEHAAction, + "AEHA", AEHA_SCHEMA.extend( { cv.Optional(CONF_CARRIER_FREQUENCY, default="38000Hz"): cv.All( @@ -2094,23 +2541,77 @@ def aeha_dumper(var, config): ), } ), + automation.ApplyField( + CONF_CARRIER_FREQUENCY, "get_data()->set_carrier_frequency({})", cg.uint32 + ), + automation.ApplyCall( + "encode({}, {})", + ( + (CONF_ADDRESS, cg.uint16), + (CONF_DATA, _BYTES, _code_table), + ), + ), + call="encode_call", ) -async def aeha_action(var, config, args): - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.uint16) - cg.add(var.set_address(template_)) - template_ = await cg.templatable( - config[CONF_DATA], args, cg.std_vector.template(cg.uint8) + + +# Hob2Hood +( + Hob2HoodData, + Hob2HoodBinarySensor, + Hob2HoodTrigger, + _, + 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]), + ) + ) ) - cg.add(var.set_data(template_)) - templ = await cg.templatable(config[CONF_CARRIER_FREQUENCY], args, cg.uint32) - cg.add(var.set_carrier_frequency(templ)) + + +@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_protocol_action( + "hob2hood", + "Hob2Hood", + HOB2HOOD_SCHEMA, + (CONF_COMMAND, "command", Hob2HoodCommand), +) # Haier -HaierData, HaierBinarySensor, HaierTrigger, HaierAction, HaierDumper = declare_protocol( - "Haier" -) -HaierAction = ns.class_("HaierAction", RemoteTransmitterActionBase) +HaierData, HaierBinarySensor, HaierTrigger, _, HaierDumper = declare_protocol("Haier") HAIER_SCHEMA = cv.Schema( { cv.Required(CONF_CODE): cv.All( @@ -2140,11 +2641,13 @@ def haier_dumper(var, config): pass -@register_action("haier", HaierAction, HAIER_SCHEMA) -async def haier_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) +register_protocol_action( + "haier", + "Haier", + HAIER_SCHEMA, + _bytes_field(CONF_CODE, "encode({})"), + call="encode_call", +) # ABBWelcome @@ -2152,7 +2655,7 @@ async def haier_action(var, config, args): ABBWelcomeData, ABBWelcomeBinarySensor, ABBWelcomeTrigger, - ABBWelcomeAction, + _, ABBWelcomeDumper, ) = declare_protocol("ABBWelcome") @@ -2204,52 +2707,28 @@ def abbwelcome_dumper(var, config): pass -@register_action("abbwelcome", ABBWelcomeAction, ABB_WELCOME_SCHEMA) -async def abbwelcome_action(var, config, args): - cg.add( - var.set_three_byte_address( - await cg.templatable(config[CONF_THREE_BYTE_ADDRESS], args, cg.bool_) - ) - ) - cg.add( - var.set_source_address( - await cg.templatable(config[CONF_SOURCE_ADDRESS], args, cg.uint32) - ) - ) - cg.add( - var.set_destination_address( - await cg.templatable(config[CONF_DESTINATION_ADDRESS], args, cg.uint32) - ) - ) - cg.add( - var.set_retransmission( - await cg.templatable(config[CONF_RETRANSMISSION], args, cg.bool_) - ) - ) - cg.add( - var.set_message_type( - await cg.templatable(config[CONF_MESSAGE_TYPE], args, cg.uint8) - ) - ) - template_ = await cg.templatable(CONF_MESSAGE_ID not in config, args, cg.bool_) - cg.add(var.set_auto_message_id(template_)) - if CONF_MESSAGE_ID in config: - cg.add( - var.set_message_id( - await cg.templatable(config[CONF_MESSAGE_ID], args, cg.uint8) - ) - ) - if CONF_DATA in config: - data_ = config[CONF_DATA] - if cg.is_template(data_): - template_ = await cg.templatable( - data_, args, cg.std_vector.template(cg.uint8) - ) - cg.add(var.set_data_template(template_)) - else: - arr_id = ID(f"{var.base}_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_))) +register_protocol_action( + "abbwelcome", + "ABBWelcome", + ABB_WELCOME_SCHEMA, + # First: the address length decides where the other fields go + automation.ApplyField( + CONF_THREE_BYTE_ADDRESS, "data.set_three_byte_address({})", cg.bool_ + ), + automation.ApplyField( + CONF_SOURCE_ADDRESS, "data.set_source_address({})", cg.uint32 + ), + automation.ApplyField( + CONF_DESTINATION_ADDRESS, "data.set_destination_address({})", cg.uint32 + ), + automation.ApplyField(CONF_RETRANSMISSION, "data.set_retransmission({})", cg.bool_), + automation.ApplyField(CONF_MESSAGE_TYPE, "data.set_message_type({})", cg.uint8), + # Without message_id, finalize() picks one + automation.ApplyCall("data.auto_message_id = true"), + automation.ApplyField(CONF_MESSAGE_ID, "data.set_fixed_message_id({})", cg.uint8), + _bytes_field(CONF_DATA, "data.set_data({})"), + automation.ApplyCall("data.finalize()"), +) # Mirage @@ -2257,7 +2736,7 @@ async def abbwelcome_action(var, config, args): MirageData, MirageBinarySensor, MirageTrigger, - MirageAction, + _, MirageDumper, ) = declare_protocol("Mirage") @@ -2283,11 +2762,13 @@ def mirage_dumper(var, config): pass -@register_action("mirage", MirageAction, MIRAGE_SCHEMA) -async def mirage_action(var, config, args): - vec_ = cg.std_vector.template(cg.uint8) - template_ = await cg.templatable(config[CONF_CODE], args, vec_, vec_) - cg.add(var.set_code(template_)) +register_protocol_action( + "mirage", + "Mirage", + MIRAGE_SCHEMA, + _bytes_field(CONF_CODE, "encode({})"), + call="encode_call", +) # Toto @@ -2295,7 +2776,7 @@ async def mirage_action(var, config, args): TotoData, TotoBinarySensor, TotoTrigger, - TotoAction, + _, TotoDumper, ) = declare_protocol("Toto") @@ -2332,17 +2813,110 @@ def toto_dumper(var, config): pass -@register_action("toto", TotoAction, TOTO_SCHEMA) -async def Toto_action(var, config, args): - template_ = await cg.templatable(config[CONF_RC_CODE_1], args, cg.uint8) - cg.add(var.set_rc_code_1(template_)) - template_ = await cg.templatable(config[CONF_RC_CODE_2], args, cg.uint8) - cg.add(var.set_rc_code_2(template_)) - template_ = await cg.templatable(config[CONF_COMMAND], args, cg.uint8) - cg.add(var.set_command(template_)) - # Set toto-specific defaults (only if user didn't configure repeat) - if CONF_REPEAT not in config: - template_ = await cg.templatable(3, args, cg.uint32) - cg.add(var.set_send_times(template_)) - template_ = await cg.templatable(36000, args, cg.uint32) - cg.add(var.set_send_wait(template_)) +def _toto_default_repeat(config: ConfigType) -> ConfigType: + # Toto remotes send each frame 3 times, 36 ms apart + if CONF_REPEAT in config: + return config + return {**config, CONF_REPEAT: {CONF_TIMES: 3, CONF_WAIT_TIME: 36000}} + + +register_protocol_action( + "toto", + "Toto", + TOTO_SCHEMA, + (CONF_RC_CODE_1, "rc_code_1", cg.uint8), + (CONF_RC_CODE_2, "rc_code_2", cg.uint8), + (CONF_COMMAND, "command", cg.uint8), + validate=_toto_default_repeat, +) + + +# IRA211 +( + IRA211Data, + IRA211BinarySensor, + IRA211Trigger, + _, + IRA211Dumper, +) = declare_protocol("IRA211") + +IRA211CommandEnum = ns.enum("IRA211Command", is_class=True) +IRA211_COMMANDS = { + "TEMP_UP": IRA211CommandEnum.IRA211_COMMAND_TEMP_UP, + "TEMP_DOWN": IRA211CommandEnum.IRA211_COMMAND_TEMP_DOWN, + "MODE": IRA211CommandEnum.IRA211_COMMAND_MODE, + "FAN": IRA211CommandEnum.IRA211_COMMAND_FAN, + "POWER": IRA211CommandEnum.IRA211_COMMAND_POWER, + "SYNC": IRA211CommandEnum.IRA211_COMMAND_SYNC, +} + +IRA211ModeEnum = ns.enum("IRA211Mode", is_class=True) +IRA211_MODES = { + "PROTECTION": IRA211ModeEnum.IRA211_MODE_PROTECTION, + "TIMER": IRA211ModeEnum.IRA211_MODE_TIMER, + "COMFORT": IRA211ModeEnum.IRA211_MODE_COMFORT, +} + +IRA211FanEnum = ns.enum("IRA211Fan", is_class=True) +IRA211_FAN_SPEEDS = { + "AUTO": IRA211FanEnum.IRA211_FAN_AUTO, + "LOW": IRA211FanEnum.IRA211_FAN_LOW, + "MEDIUM": IRA211FanEnum.IRA211_FAN_MEDIUM, + "HIGH": IRA211FanEnum.IRA211_FAN_HIGH, +} + + +def _half_degree_step(value: float) -> float: + if value * 2 != int(value * 2): + raise cv.Invalid("Temperature must be a multiple of 0.5") + return value + + +IRA211_SCHEMA = cv.Schema( + { + cv.Required(CONF_COMMAND): cv.enum(IRA211_COMMANDS, upper=True), + cv.Optional(CONF_TEMPERATURE, default=20): cv.All( + cv.float_range(min=5, max=35), _half_degree_step + ), + cv.Optional(CONF_MODE, default="COMFORT"): cv.enum(IRA211_MODES, upper=True), + cv.Optional(CONF_FAN_MODE, default="AUTO"): cv.enum( + IRA211_FAN_SPEEDS, upper=True + ), + } +) + + +@register_binary_sensor("ira211", IRA211BinarySensor, IRA211_SCHEMA) +def ira211_binary_sensor(var: MockObj, config: ConfigType) -> None: + cg.add( + var.set_data( + cg.StructInitializer( + IRA211Data, + ("command", config[CONF_COMMAND]), + ("half_degrees", int(config[CONF_TEMPERATURE] * 2)), + ("mode", config[CONF_MODE]), + ("fan", config[CONF_FAN_MODE]), + ) + ) + ) + + +@register_trigger("ira211", IRA211Trigger, IRA211Data) +def ira211_trigger(var: MockObj, config: ConfigType) -> None: + """The trigger takes no options beyond the automation.""" + + +@register_dumper("ira211", IRA211Dumper) +def ira211_dumper(var: MockObj, config: ConfigType) -> None: + """The dumper takes no options.""" + + +register_protocol_action( + "ira211", + "IRA211", + IRA211_SCHEMA, + (CONF_COMMAND, "command", IRA211CommandEnum), + automation.ApplyField(CONF_TEMPERATURE, "data.set_temperature({})", cg.float_), + (CONF_MODE, "mode", IRA211ModeEnum), + (CONF_FAN_MODE, "fan", IRA211FanEnum), +) diff --git a/esphome/components/remote_base/abbwelcome_protocol.cpp b/esphome/components/remote_base/abbwelcome_protocol.cpp index 2000148ca8..638f33687a 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.cpp +++ b/esphome/components/remote_base/abbwelcome_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.abbwelcome"; +ESPHOME_LOG_TAG(TAG, "remote.abbwelcome"); static constexpr uint32_t BIT_ONE_SPACE_US = 102; static constexpr uint32_t BIT_ZERO_MARK_US = 32; // 18-44 diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index a309c124ee..ecd27a95a5 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "remote_base.h" #include @@ -111,14 +112,21 @@ class ABBWelcomeData { return (this->data_[7] << 16) + (this->data_[8] << 8) + this->data_[9]; } void set_message_id(uint8_t message_id) { this->data_[4 + 2 * this->get_address_length()] = message_id; } + /// A configured id, which finalize() keeps instead of picking one + void set_fixed_message_id(uint8_t message_id) { + this->set_message_id(message_id); + this->auto_message_id = false; + } uint8_t get_message_id() const { return this->data_[4 + 2 * this->get_address_length()]; } - void set_data(std::vector data) { - uint8_t size = std::min(MAX_DATA_LENGTH, static_cast(data.size())); + /// data may be a PROGMEM table + void set_data(const uint8_t *data, size_t len) { + uint8_t size = static_cast(std::min(MAX_DATA_LENGTH, len)); this->data_[2] &= (0xff ^ DATA_LENGTH_MASK); this->data_[2] |= (size & DATA_LENGTH_MASK); if (size) - std::copy_n(data.begin(), size, this->data_.begin() + 5 + 2 * this->get_address_length()); + progmem_memcpy(this->data_.data() + 5 + 2 * this->get_address_length(), data, size); } + void set_data(const std::vector &data) { this->set_data(data.data(), data.size()); } std::vector get_data() const { std::vector data(this->data_.begin() + 5 + 2 * this->get_address_length(), this->data_.begin() + 5 + 2 * this->get_address_length() + this->get_data_size()); @@ -225,50 +233,4 @@ class ABBWelcomeBinarySensor : public RemoteReceiverBinarySensorBase { using ABBWelcomeTrigger = RemoteReceiverTrigger; using ABBWelcomeDumper = RemoteReceiverDumper; -template class ABBWelcomeAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(uint32_t, source_address) - TEMPLATABLE_VALUE(uint32_t, destination_address) - TEMPLATABLE_VALUE(bool, retransmission) - TEMPLATABLE_VALUE(bool, three_byte_address) - TEMPLATABLE_VALUE(uint8_t, message_type) - TEMPLATABLE_VALUE(uint8_t, message_id) - TEMPLATABLE_VALUE(bool, auto_message_id) - 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 encode(RemoteTransmitData *dst, Ts... x) override { - ABBWelcomeData data; - data.set_three_byte_address(this->three_byte_address_.value(x...)); - data.set_source_address(this->source_address_.value(x...)); - data.set_destination_address(this->destination_address_.value(x...)); - data.set_retransmission(this->retransmission_.value(x...)); - data.set_message_type(this->message_type_.value(x...)); - data.set_message_id(this->message_id_.value(x...)); - data.auto_message_id = this->auto_message_id_.value(x...); - std::vector data_vec; - if (this->len_ > 0) { - // Static mode: copy from flash to vector - data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else if (this->len_ < 0) { - // Template mode: call function - data_vec = this->data_.func(x...); - } - data.set_data(data_vec); - data.finalize(); - ABBWelcomeProtocol().encode(dst, data); - } - - protected: - ssize_t len_{0}; // <0 = 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_; -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/aeha_protocol.cpp b/esphome/components/remote_base/aeha_protocol.cpp index 69f91ba90e..70454dbc49 100644 --- a/esphome/components/remote_base/aeha_protocol.cpp +++ b/esphome/components/remote_base/aeha_protocol.cpp @@ -1,10 +1,11 @@ #include "aeha_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" #include namespace esphome::remote_base { -static const char *const TAG = "remote.aeha"; +ESPHOME_LOG_TAG(TAG, "remote.aeha"); static constexpr uint16_t BITWISE = 425; static constexpr uint16_t HEADER_HIGH_US = BITWISE * 8; @@ -14,20 +15,21 @@ static constexpr uint16_t BIT_ONE_LOW_US = BITWISE * 3; static constexpr uint16_t BIT_ZERO_LOW_US = BITWISE; static constexpr uint16_t TRAILER = BITWISE; -void AEHAProtocol::encode(RemoteTransmitData *dst, const AEHAData &data) { - dst->reserve(2 + 32 + (data.data.size() * 2) + 1); +void AEHAProtocol::encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len) { + dst->reserve(2 + 32 + (len * 2) + 1); dst->item(HEADER_HIGH_US, HEADER_LOW_US); for (uint16_t mask = 1 << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { + if (address & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); } else { dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); } } - for (uint8_t bit : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t bit = progmem_read_byte(data + i); for (uint8_t mask = 1 << 7; mask != 0; mask >>= 1) { if (bit & mask) { dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); diff --git a/esphome/components/remote_base/aeha_protocol.h b/esphome/components/remote_base/aeha_protocol.h index 98a5501155..ab762612e1 100644 --- a/esphome/components/remote_base/aeha_protocol.h +++ b/esphome/components/remote_base/aeha_protocol.h @@ -15,7 +15,14 @@ struct AEHAData { class AEHAProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const AEHAData &data); + void encode(RemoteTransmitData *dst, const AEHAData &data) { + this->encode(dst, data.address, data.data.data(), data.data.size()); + } + /// data may be a PROGMEM table + void encode(RemoteTransmitData *dst, uint16_t address, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, uint16_t address, const std::vector &data) { + this->encode(dst, address, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const AEHAData &data); @@ -25,20 +32,4 @@ class AEHAProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(AEHA) -template class AEHAAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(std::vector, data) - TEMPLATABLE_VALUE(uint32_t, carrier_frequency); - - void set_data(const std::vector &data) { data_ = data; } - void encode(RemoteTransmitData *dst, Ts... x) override { - AEHAData data{}; - data.address = this->address_.value(x...); - data.data = this->data_.value(x...); - dst->set_carrier_frequency(this->carrier_frequency_.value(x...)); - AEHAProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/beo4_protocol.cpp b/esphome/components/remote_base/beo4_protocol.cpp index 9291501ea3..e79e05d839 100644 --- a/esphome/components/remote_base/beo4_protocol.cpp +++ b/esphome/components/remote_base/beo4_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.beo4"; +ESPHOME_LOG_TAG(TAG, "remote.beo4"); // beo4 pulse width, high=carrier_pulse low=data_pulse constexpr uint16_t PW_CARR_US = 200; // carrier pulse length diff --git a/esphome/components/remote_base/beo4_protocol.h b/esphome/components/remote_base/beo4_protocol.h index ed9d6aa671..539aa0d75b 100644 --- a/esphome/components/remote_base/beo4_protocol.h +++ b/esphome/components/remote_base/beo4_protocol.h @@ -23,19 +23,4 @@ class Beo4Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Beo4) -template class Beo4Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, source) - TEMPLATABLE_VALUE(uint8_t, command) - TEMPLATABLE_VALUE(uint8_t, repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - Beo4Data data{}; - data.source = this->source_.value(x...); - data.command = this->command_.value(x...); - data.repeats = this->repeats_.value(x...); - Beo4Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/brennenstuhl_protocol.cpp b/esphome/components/remote_base/brennenstuhl_protocol.cpp index 35261dc00a..2537ba5560 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.cpp +++ b/esphome/components/remote_base/brennenstuhl_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.brennenstuhl"; +ESPHOME_LOG_TAG(TAG, "remote.brennenstuhl"); // receiver timing ranges [µs] constexpr uint32_t START_PULSE_MIN = 200; diff --git a/esphome/components/remote_base/brennenstuhl_protocol.h b/esphome/components/remote_base/brennenstuhl_protocol.h index bfea463b7d..f85df95305 100644 --- a/esphome/components/remote_base/brennenstuhl_protocol.h +++ b/esphome/components/remote_base/brennenstuhl_protocol.h @@ -20,15 +20,4 @@ class BrennenstuhlProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Brennenstuhl) -template class BrennenstuhlAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - BrennenstuhlData data{}; - data.code = this->code_.value(x...); - BrennenstuhlProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/byronsx_protocol.cpp b/esphome/components/remote_base/byronsx_protocol.cpp index 11d6d975d1..468b11f695 100644 --- a/esphome/components/remote_base/byronsx_protocol.cpp +++ b/esphome/components/remote_base/byronsx_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.byronsx"; +ESPHOME_LOG_TAG(TAG, "remote.byronsx"); static constexpr uint32_t BIT_TIME_US = 333; static constexpr uint8_t NBITS_ADDRESS = 8; diff --git a/esphome/components/remote_base/byronsx_protocol.h b/esphome/components/remote_base/byronsx_protocol.h index c71390c267..67a2d5a06a 100644 --- a/esphome/components/remote_base/byronsx_protocol.h +++ b/esphome/components/remote_base/byronsx_protocol.h @@ -28,17 +28,4 @@ class ByronSXProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(ByronSX) -template class ByronSXAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ByronSXData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - ByronSXProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/canalsat_protocol.cpp b/esphome/components/remote_base/canalsat_protocol.cpp index eafa98ebcc..ac83c37227 100644 --- a/esphome/components/remote_base/canalsat_protocol.cpp +++ b/esphome/components/remote_base/canalsat_protocol.cpp @@ -3,8 +3,8 @@ namespace esphome::remote_base { -static const char *const CANALSAT_TAG = "remote.canalsat"; -static const char *const CANALSATLD_TAG = "remote.canalsatld"; +ESPHOME_LOG_TAG(CANALSAT_TAG, "remote.canalsat"); +ESPHOME_LOG_TAG(CANALSATLD_TAG, "remote.canalsatld"); static constexpr uint16_t CANALSAT_FREQ = 55500; static constexpr uint16_t CANALSATLD_FREQ = 56000; diff --git a/esphome/components/remote_base/canalsat_protocol.h b/esphome/components/remote_base/canalsat_protocol.h index 09bead18b3..31e4f5c47e 100644 --- a/esphome/components/remote_base/canalsat_protocol.h +++ b/esphome/components/remote_base/canalsat_protocol.h @@ -41,36 +41,6 @@ class CanalSatLDProtocol : public CanalSatBaseProtocol { DECLARE_REMOTE_PROTOCOL(CanalSat) -template class CanalSatAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, device) - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - CanalSatData data{}; - data.device = this->device_.value(x...); - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - CanalSatProtocol().encode(dst, data); - } -}; - DECLARE_REMOTE_PROTOCOL(CanalSatLD) -template class CanalSatLDAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, device) - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - CanalSatData data{}; - data.device = this->device_.value(x...); - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - CanalSatLDProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/coolix_protocol.cpp b/esphome/components/remote_base/coolix_protocol.cpp index 53e1d59f13..e2e54afadb 100644 --- a/esphome/components/remote_base/coolix_protocol.cpp +++ b/esphome/components/remote_base/coolix_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.coolix"; +ESPHOME_LOG_TAG(TAG, "remote.coolix"); static const int32_t TICK_US = 560; static const int32_t HEADER_MARK_US = 8 * TICK_US; diff --git a/esphome/components/remote_base/coolix_protocol.h b/esphome/components/remote_base/coolix_protocol.h index 29a306ce29..d5ce29d436 100644 --- a/esphome/components/remote_base/coolix_protocol.h +++ b/esphome/components/remote_base/coolix_protocol.h @@ -28,12 +28,4 @@ class CoolixProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Coolix) -template class CoolixAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(uint32_t, first) - TEMPLATABLE_VALUE(uint32_t, second) - void encode(RemoteTransmitData *dst, Ts... x) override { - CoolixProtocol().encode(dst, {this->first_.value(x...), this->second_.value(x...)}); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dish_protocol.cpp b/esphome/components/remote_base/dish_protocol.cpp index 9a6420afd5..6877d661bd 100644 --- a/esphome/components/remote_base/dish_protocol.cpp +++ b/esphome/components/remote_base/dish_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.dish"; +ESPHOME_LOG_TAG(TAG, "remote.dish"); static constexpr uint32_t HEADER_HIGH_US = 400; static constexpr uint32_t HEADER_LOW_US = 6100; @@ -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 f319b55f43..85ea1faa38 100644 --- a/esphome/components/remote_base/dish_protocol.h +++ b/esphome/components/remote_base/dish_protocol.h @@ -20,17 +20,4 @@ class DishProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dish) -template class DishAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DishData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - DishProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dooya_protocol.cpp b/esphome/components/remote_base/dooya_protocol.cpp index 6dc7926b4a..5291d69345 100644 --- a/esphome/components/remote_base/dooya_protocol.cpp +++ b/esphome/components/remote_base/dooya_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.dooya"; +ESPHOME_LOG_TAG(TAG, "remote.dooya"); static constexpr uint32_t HEADER_HIGH_US = 5000; static constexpr uint32_t HEADER_LOW_US = 1500; diff --git a/esphome/components/remote_base/dooya_protocol.h b/esphome/components/remote_base/dooya_protocol.h index 954c3cf1d3..06f77f8050 100644 --- a/esphome/components/remote_base/dooya_protocol.h +++ b/esphome/components/remote_base/dooya_protocol.h @@ -27,21 +27,4 @@ class DooyaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dooya) -template class DooyaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, id) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, button) - TEMPLATABLE_VALUE(uint8_t, check) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DooyaData data{}; - data.id = this->id_.value(x...); - data.channel = this->channel_.value(x...); - data.button = this->button_.value(x...); - data.check = this->check_.value(x...); - DooyaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.cpp b/esphome/components/remote_base/drayton_protocol.cpp index 2261bd04e9..1bb2a68417 100644 --- a/esphome/components/remote_base/drayton_protocol.cpp +++ b/esphome/components/remote_base/drayton_protocol.cpp @@ -5,20 +5,19 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.drayton"; +ESPHOME_LOG_TAG(TAG, "remote.drayton"); static constexpr uint32_t BIT_TIME_US = 500; static constexpr uint8_t CARRIER_KHZ = 2; static constexpr uint8_t NBITS_PREAMBLE = 12; static constexpr uint8_t NBITS_SYNC = 4; static constexpr uint8_t NBITS_ADDRESS = 16; -static constexpr uint8_t NBITS_CHANNEL = 5; +static constexpr uint8_t NBITS_DATA = 4; +static constexpr uint8_t NBITS_CHECKSUM = 8; static constexpr uint8_t NBITS_COMMAND = 7; -static constexpr uint8_t NDATABITS = NBITS_ADDRESS + NBITS_CHANNEL + NBITS_COMMAND; -static constexpr uint8_t MIN_RX_SRC = (NDATABITS + NBITS_SYNC / 2); - -static constexpr uint8_t CMD_ON = 0x41; -static constexpr uint8_t CMD_OFF = 0x02; +static constexpr uint8_t NBITS_CHANNEL = 5; +static constexpr uint8_t NBITS_PKT = NBITS_ADDRESS + NBITS_DATA + NBITS_CHECKSUM; +static constexpr uint8_t MIN_RX_SRC = (NBITS_PKT + NBITS_SYNC / 2); /* Drayton Protocol @@ -31,59 +30,72 @@ The number of these bits seen at the receiver varies depending on the state of the bias when the packet transmission starts. The receiver algoritmn takes account of this. -The packet appears to be Manchester encoded, with a '10' tranmitted pair -representing a '1' bit and a '01' pair representing a '0' bit. Each packet is -begun with a '1100' syncronisation symbol which breaks this rule. Following +The packet is Manchester coded, with a '10' tranmitted pair representing +a '1' bit and a '01' pair representing a '0' bit. Each packet is begun +with a '1100' syncronisation symbol which breaks this rule. Following the sync are 28 '01' or '10' pairs. -------------------- -Boiler On Command as received: +Sample Boiler On Command as received: 101010101010110001101001010101101001010101010101100101010101101001011001 ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-1-0-0-0-0-0-1-1-0-0-1-0 (Where pppp represents the preamble bits and SSSS represents the sync symbol) -28 bits of data received 01100001100000001000001 10010 (bin) or 6180832 (hex) +28 bits received 01100001100000001000001 10010 (bin) or 6180832 (hex) -Boiler Off Command as received: +Sample Boiler Off Command as received: 101010101010110001101001010101101001010101010101010101010110011001011001 ppppppppppppSSSS-0-1-1-0-0-0-0-1-1-0-0-0-0-0-0-0-0-0-0-0-0-1-0-1-0-0-1-0 -28 bits of data received 0110000110000000000001010010 (bin) or 6180052 (hex) +28 bits received 0110000110000000000001010010 (bin) or 6180052 (hex) -------------------- -I have used 'RFLink' software (RLink Firmware Version: 1.1 Revision: 48) to -capture and retransmit the Digistat packets. RFLink splits each packet into an -ID, SWITCH, and CMD field. +The 28 bits consist of 20 bits of payload and 8 bits of checksum. -0;17;Drayton;ID=c300;SWITCH=12;CMD=ON; -20;18;Drayton;ID=c300;SWITCH=12;CMD=OFF; +In order of transmission the payload consists of the following fields: --------------------- +Bits Description +12 ID of the thermostat. A Digistat receiver will only respond to a packet if its ID + matches the ID that the receiver has previously learnt. +4 Pairing field, normally 0000. The first packets sent by the thermostat after its + batteries are inserted have this field set to 0001, which lets a Digistat receiver + in pairing mode learn the ID of the thermostat. +1 Thermostat on (1) or off (0). +1 Always 0. +1 Low battery (1) or battery OK (0). +1 Always 0. -Spliting my received data into three parts of 16, 7 and 5 bits gives address, -channel and Command values of: +The YAML/lambda 'address' is the first 16 bits as sent (the 12 bit ID followed by the +pairing field, so the pairing flag is the address LSB), and 'data' is the next 4 bits +(0x8 = on, 0x2 = low battery). -On 6180832 0110000110000000 1000001 10010 -address: '0x6180' channel: '0x12' command: '0x41' +Checksum: see calc_cs(); the +1 when the sum is above 0x7F is empirical. The captured +packets above give 0x32 (On) and 0x52 (Off). Packets that fail it are logged at verbose level. -Off 6180052 0110000110000000 0000010 10010 -address: '0x6180' channel: '0x12' command: '0x02' - -These values are slightly different to those used by RFLink (the RFLink -ID/Adress value is rotated/manipulated), and I don't know who's interpretation -is correct. A larger data sample would help (I have only found five different -packet captures online) or definitive information from Drayton. - -Splitting each packet in this way works well for me with esphome. Any -corrections or additional data samples would be gratefully received. - -marshn +Legacy split (from RFLink): address (16), command (7: data + top 3 checksum bits), channel (5: low checksum bits). +When channel and command are set they are sent as is, with no checksum calculated. */ +static uint8_t calc_cs(uint32_t out_data) { + uint16_t cs = 0; + uint32_t rev = reverse_bits(out_data); + + for (uint8_t i = 4; i <= 20; i += 4) { + cs <<= 1; + cs += (rev >> i) & 0x0F; + } + + if (cs > 0x7F) { + cs += 1; + } + // Sent LSB first + return reverse_bits(uint8_t(cs << 1)); +} + void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { uint16_t khz = CARRIER_KHZ; dst->set_carrier_frequency(khz * 1000); @@ -108,17 +120,21 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { } } - ESP_LOGD(TAG, "Send Drayton: address=%04x channel=%03x cmd=%02x", data.address, data.channel, data.command); + if (data.channel || data.command) { + out_data = data.address; + out_data <<= NBITS_COMMAND; + out_data |= data.command; + out_data <<= NBITS_CHANNEL; + out_data |= data.channel; + } else { + out_data = data.address << (NBITS_DATA + NBITS_CHECKSUM); + out_data |= (data.data & ((1u << NBITS_DATA) - 1)) << NBITS_CHECKSUM; // a lambda may exceed 4 bits + out_data |= calc_cs(out_data); + } + ESP_LOGD(TAG, "addr=%04x data=%x channel=%02x command=%02x", data.address, data.data, data.channel, data.command); + ESP_LOGV(TAG, "out_data %07" PRIx32, out_data); - out_data = data.address; - out_data <<= NBITS_COMMAND; - out_data |= data.command; - out_data <<= NBITS_CHANNEL; - out_data |= data.channel; - - ESP_LOGV(TAG, "Send Drayton: out_data %08" PRIx32, out_data); - - for (uint32_t mask = 1UL << (NDATABITS - 1); mask != 0; mask >>= 1) { + for (uint32_t mask = 1UL << (NBITS_PKT - 1); mask != 0; mask >>= 1) { if (out_data & mask) { dst->mark(BIT_TIME_US); dst->space(BIT_TIME_US); @@ -130,21 +146,9 @@ void DraytonProtocol::encode(RemoteTransmitData *dst, const DraytonData &data) { } optional DraytonProtocol::decode(RemoteReceiveData src) { - DraytonData out{ - .address = 0, - .channel = 0, - .command = 0, - }; + DraytonData out{}; while (src.size() - src.get_index() >= MIN_RX_SRC) { - ESP_LOGVV(TAG, - "Decode Drayton: %" PRId32 ", %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 - " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 - " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 " %" PRId32 "", - src.size() - src.get_index(), src.peek(0), src.peek(1), src.peek(2), src.peek(3), src.peek(4), - src.peek(5), src.peek(6), src.peek(7), src.peek(8), src.peek(9), src.peek(10), src.peek(11), src.peek(12), - src.peek(13), src.peek(14), src.peek(15), src.peek(16), src.peek(17), src.peek(18), src.peek(19)); - // If first preamble item is a space, skip it if (src.peek_space_at_least(1)) { src.advance(1); @@ -152,12 +156,12 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // Look for sync pulse, after. If sucessful index points to space of sync symbol while (src.size() - src.get_index() >= MIN_RX_SRC) { - ESP_LOGVV(TAG, "Decode Drayton: sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), - src.peek(), src.peek(1)); + ESP_LOGVV(TAG, "sync search %" PRIu32 ", %" PRId32 " %" PRId32, src.size() - src.get_index(), src.peek(), + src.peek(1)); if (src.peek_mark(2 * BIT_TIME_US) && (src.peek_space(2 * BIT_TIME_US, 1) || src.peek_space(3 * BIT_TIME_US, 1))) { src.advance(1); - ESP_LOGVV(TAG, "Decode Drayton: Found SYNC, - %" PRIu32, src.get_index()); + ESP_LOGVV(TAG, "Found SYNC %" PRIu32, src.get_index()); break; } else { src.advance(2); @@ -165,8 +169,8 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { } // No point continuing if not enough samples remaining to complete a packet - if (src.size() - src.get_index() < NDATABITS) { - ESP_LOGV(TAG, "Decode Drayton: Fail 1, - %" PRIu32, src.get_index()); + if (src.size() - src.get_index() < NBITS_PKT) { + ESP_LOGV(TAG, "Fail1 %" PRIu32, src.get_index()); break; } @@ -174,17 +178,14 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // Extract first bit // Checks next bit to leave index pointing correctly uint32_t out_data = 0; - uint8_t bit = NDATABITS - 1; - ESP_LOGVV(TAG, "Decode Drayton: first bit %" PRId32 " %" PRId32 ", %" PRId32, src.peek(0), src.peek(1), - src.peek(2)); + uint8_t bit = NBITS_PKT - 1; if (src.expect_space(3 * BIT_TIME_US) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { out_data |= 0 << bit; } else if (src.expect_space(2 * BIT_TIME_US) && src.expect_mark(BIT_TIME_US) && (src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) { out_data |= 1 << bit; } else { - ESP_LOGV(TAG, "Decode Drayton: Fail 2, - %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), - src.peek(1)); + ESP_LOGVV(TAG, "Fail2 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1)); continue; } @@ -192,7 +193,7 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { // if there is no transition at the start of the bit period, then the transition in the middle of // the previous bit period. while (--bit >= 1) { - ESP_LOGVV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data); + ESP_LOGVV(TAG, "Data %2d %08" PRIx32, bit, out_data); if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) && (src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) { out_data |= 0 << bit; @@ -205,8 +206,7 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { } if (bit > 0) { - ESP_LOGVV(TAG, "Decode Drayton: Fail 3, %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), - src.peek(1)); + ESP_LOGVV(TAG, "Fail3 %" PRId32 " %" PRId32 " %" PRId32, src.peek(-1), src.peek(0), src.peek(1)); continue; } @@ -218,21 +218,28 @@ optional DraytonProtocol::decode(RemoteReceiveData src) { continue; } - ESP_LOGV(TAG, "Decode Drayton: Data, %2d %08" PRIx32, bit, out_data); + uint8_t cs = out_data & 0xFF; + uint8_t expected_cs = calc_cs(out_data); - out.channel = (uint8_t) (out_data & 0x1F); - out_data >>= NBITS_CHANNEL; - out.command = (uint8_t) (out_data & 0x7F); - out_data >>= NBITS_COMMAND; - out.address = (uint16_t) (out_data & 0xFFFF); + if (cs != expected_cs) { + ESP_LOGV(TAG, "Dropped %07" PRIx32 ": checksum 0x%02x, expected 0x%02x", out_data, cs, expected_cs); + continue; + } + + ESP_LOGV(TAG, "Data %07" PRIx32, out_data); + + out.data = (out_data >> NBITS_CHECKSUM) & ((1u << NBITS_DATA) - 1); + out.address = out_data >> (NBITS_CHECKSUM + NBITS_DATA); + out.channel = out_data & ((1u << NBITS_CHANNEL) - 1); + out.command = (out_data >> NBITS_CHANNEL) & ((1u << NBITS_COMMAND) - 1); return out; } return {}; } + void DraytonProtocol::dump(const DraytonData &data) { - ESP_LOGI(TAG, "Received Drayton: address=0x%04X (0x%04x), channel=0x%03x command=0x%03X", data.address, - ((data.address << 1) & 0xffff), data.channel, data.command); + ESP_LOGI(TAG, "Received Drayton: address=0x%04X, data=0x%X", data.address, data.data); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/drayton_protocol.h b/esphome/components/remote_base/drayton_protocol.h index 4e879f0f75..83988fbaa6 100644 --- a/esphome/components/remote_base/drayton_protocol.h +++ b/esphome/components/remote_base/drayton_protocol.h @@ -11,10 +11,10 @@ struct DraytonData { uint16_t address; uint8_t channel; uint8_t command; + uint8_t data; // last, so positional initializers written for the legacy layout keep their meaning - bool operator==(const DraytonData &rhs) const { - return address == rhs.address && channel == rhs.channel && command == rhs.command; - } + // channel/command: legacy split of the same bits, filled on decode, ignored by operator== + bool operator==(const DraytonData &rhs) const { return address == rhs.address && data == rhs.data; } }; class DraytonProtocol : public RemoteProtocol { @@ -26,19 +26,4 @@ class DraytonProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Drayton) -template class DraytonAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DraytonData data{}; - data.address = this->address_.value(x...); - data.channel = this->channel_.value(x...); - data.command = this->command_.value(x...); - DraytonProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/dyson_protocol.cpp b/esphome/components/remote_base/dyson_protocol.cpp index 942b40d26f..f6a7943ebd 100644 --- a/esphome/components/remote_base/dyson_protocol.cpp +++ b/esphome/components/remote_base/dyson_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.dyson"; +ESPHOME_LOG_TAG(TAG, "remote.dyson"); // pulsewidth [µs] constexpr uint32_t PW_MARK_US = 780; diff --git a/esphome/components/remote_base/dyson_protocol.h b/esphome/components/remote_base/dyson_protocol.h index 663e50fb4b..5afa28e07b 100644 --- a/esphome/components/remote_base/dyson_protocol.h +++ b/esphome/components/remote_base/dyson_protocol.h @@ -28,17 +28,4 @@ class DysonProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Dyson) -template class DysonAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, code) - TEMPLATABLE_VALUE(uint8_t, index) - - void encode(RemoteTransmitData *dst, Ts... x) override { - DysonData data{}; - data.code = this->code_.value(x...); - data.index = this->index_.value(x...); - DysonProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/gobox_protocol.cpp b/esphome/components/remote_base/gobox_protocol.cpp index 1d67be86b8..affafa694c 100644 --- a/esphome/components/remote_base/gobox_protocol.cpp +++ b/esphome/components/remote_base/gobox_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.gobox"; +ESPHOME_LOG_TAG(TAG, "remote.gobox"); constexpr uint32_t BIT_MARK_US = 580; // 70us seems like a safe time delta for the receiver... constexpr uint32_t BIT_ONE_SPACE_US = 1640; diff --git a/esphome/components/remote_base/gobox_protocol.h b/esphome/components/remote_base/gobox_protocol.h index 0c8797af70..55e8137b75 100644 --- a/esphome/components/remote_base/gobox_protocol.h +++ b/esphome/components/remote_base/gobox_protocol.h @@ -38,15 +38,4 @@ class GoboxProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Gobox) -template class GoboxAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, code); - - void encode(RemoteTransmitData *dst, Ts... x) override { - GoboxData data{}; - data.code = this->code_.value(x...); - GoboxProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/haier_protocol.cpp b/esphome/components/remote_base/haier_protocol.cpp index 8801f1049a..07e3793520 100644 --- a/esphome/components/remote_base/haier_protocol.cpp +++ b/esphome/components/remote_base/haier_protocol.cpp @@ -1,10 +1,11 @@ #include "haier_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::remote_base { -static const char *const TAG = "remote.haier"; +ESPHOME_LOG_TAG(TAG, "remote.haier"); constexpr uint32_t HEADER_LOW_US = 3100; constexpr uint32_t HEADER_HIGH_US = 4400; @@ -29,16 +30,17 @@ void HaierProtocol::encode_byte_(RemoteTransmitData *dst, uint8_t item) { } } -void HaierProtocol::encode(RemoteTransmitData *dst, const HaierData &data) { +void HaierProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 16)); + dst->reserve(5 + ((len + 1) * 16)); dst->mark(HEADER_LOW_US); dst->space(HEADER_LOW_US); dst->mark(HEADER_LOW_US); dst->space(HEADER_HIGH_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t item = progmem_read_byte(data + i); this->encode_byte_(dst, item); checksum += item; } diff --git a/esphome/components/remote_base/haier_protocol.h b/esphome/components/remote_base/haier_protocol.h index e1fd60411f..0594049471 100644 --- a/esphome/components/remote_base/haier_protocol.h +++ b/esphome/components/remote_base/haier_protocol.h @@ -13,7 +13,12 @@ struct HaierData { class HaierProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const HaierData &data); + void encode(RemoteTransmitData *dst, const HaierData &data) { this->encode(dst, data.data.data(), data.data.size()); } + /// data may be a PROGMEM table + void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, const std::vector &data) { + this->encode(dst, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const HaierData &data); @@ -23,15 +28,4 @@ class HaierProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Haier) -template class HaierAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(std::vector, code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - HaierData data{}; - data.data = this->code_.value(x...); - HaierProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/hob2hood_protocol.cpp b/esphome/components/remote_base/hob2hood_protocol.cpp new file mode 100644 index 0000000000..95683ba8cc --- /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 { + +ESPHOME_LOG_TAG(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..dd3288103c --- /dev/null +++ b/esphome/components/remote_base/hob2hood_protocol.h @@ -0,0 +1,31 @@ +#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) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/ira211_protocol.cpp b/esphome/components/remote_base/ira211_protocol.cpp new file mode 100644 index 0000000000..b9cd6ec886 --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.cpp @@ -0,0 +1,292 @@ +#include "ira211_protocol.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.ira211"); + +static constexpr uint32_t CARRIER_FREQUENCY = 38000; +static constexpr uint32_t T_US = 800; +static constexpr uint32_t HEADER_US = 19 * T_US / 2; // 7600 us, also the gap that separates frames +// Midway between the longest data run (9 bits, 7200 us) and the gap, so a gap captured a little short still +// ends the frame +static constexpr uint32_t GAP_MIN_US = HEADER_US - T_US / 4; +static constexpr uint8_t MIN_HALF_DEGREES = static_cast(IRA211_MIN_TEMPERATURE * 2); +static constexpr uint8_t MAX_HALF_DEGREES = static_cast(IRA211_MAX_TEMPERATURE * 2); + +static constexpr uint8_t DEVICE_ID = 0x66; +static constexpr uint8_t BITS_PER_PACKET = 10; +static constexpr uint8_t MIN_PACKETS = 4; +static constexpr uint8_t MAX_PACKETS = 7; +static constexpr uint8_t MAX_BITS = MAX_PACKETS * BITS_PER_PACKET; + +// Fields each command carries, in wire order after the command byte: temperature (whole degrees +// then tenths), mode, fan. +static constexpr uint8_t FIELD_TEMPERATURE = 1 << 0; +static constexpr uint8_t FIELD_MODE = 1 << 1; +static constexpr uint8_t FIELD_FAN = 1 << 2; + +struct CommandLayout { + IRA211Command command; + uint8_t fields; +}; + +static const CommandLayout LAYOUTS[] PROGMEM = { + {IRA211Command::IRA211_COMMAND_TEMP_UP, FIELD_TEMPERATURE}, + {IRA211Command::IRA211_COMMAND_TEMP_DOWN, FIELD_TEMPERATURE}, + {IRA211Command::IRA211_COMMAND_MODE, FIELD_MODE}, + {IRA211Command::IRA211_COMMAND_FAN, FIELD_FAN}, + {IRA211Command::IRA211_COMMAND_POWER, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN}, + {IRA211Command::IRA211_COMMAND_SYNC, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN}, +}; + +static optional find_layout(IRA211Command command) { + for (const auto &slot : LAYOUTS) { + CommandLayout layout; + progmem_memcpy(&layout, &slot, sizeof(layout)); + if (layout.command == command) + return layout; + } + return {}; +} + +// Device ID, command, fields, checksum +static uint8_t packet_count(const CommandLayout &layout) { + return 3 + ((layout.fields & FIELD_TEMPERATURE) ? 2 : 0) + ((layout.fields & FIELD_MODE) ? 1 : 0) + + ((layout.fields & FIELD_FAN) ? 1 : 0); +} + +// Value to wire byte and back; the transform is its own inverse +static uint8_t wire_xform(uint8_t value) { return reverse_bits(static_cast(~value)); } + +static bool is_known_mode(uint8_t value) { + switch (static_cast(value)) { + case IRA211Mode::IRA211_MODE_PROTECTION: + case IRA211Mode::IRA211_MODE_TIMER: + case IRA211Mode::IRA211_MODE_COMFORT: + return true; + default: + return false; + } +} + +static bool is_known_fan(uint8_t value) { + switch (static_cast(value)) { + case IRA211Fan::IRA211_FAN_AUTO: + case IRA211Fan::IRA211_FAN_LOW: + case IRA211Fan::IRA211_FAN_MEDIUM: + case IRA211Fan::IRA211_FAN_HIGH: + return true; + default: + return false; + } +} + +static uint8_t checksum(const uint8_t *bytes, uint8_t count) { + uint8_t sum = count; + for (uint8_t i = 0; i < count; i++) + sum += reverse_bits(bytes[i]); + return reverse_bits(sum); +} + +bool IRA211Data::operator==(const IRA211Data &rhs) const { + if (this->command != rhs.command) + return false; + const optional layout = find_layout(this->command); + if (!layout.has_value()) + return true; + if ((layout->fields & FIELD_TEMPERATURE) && this->half_degrees != rhs.half_degrees) + return false; + if ((layout->fields & FIELD_MODE) && this->mode != rhs.mode) + return false; + return !(layout->fields & FIELD_FAN) || this->fan == rhs.fan; +} + +void IRA211Protocol::encode(RemoteTransmitData *dst, const IRA211Data &data) { + const optional layout = find_layout(data.command); + if (!layout.has_value()) + return; + + uint8_t bytes[MAX_PACKETS]; + uint8_t count = 0; + bytes[count++] = DEVICE_ID; + bytes[count++] = wire_xform(static_cast(data.command)); + if (layout->fields & FIELD_TEMPERATURE) { + bytes[count++] = wire_xform(data.half_degrees / 2); + bytes[count++] = wire_xform((data.half_degrees & 1) ? 5 : 0); + } + if (layout->fields & FIELD_MODE) + bytes[count++] = wire_xform(static_cast(data.mode)); + if (layout->fields & FIELD_FAN) + bytes[count++] = wire_xform(static_cast(data.fan)); + bytes[count] = checksum(bytes, count); + count++; + + dst->set_carrier_frequency(CARRIER_FREQUENCY); + // Preamble, at most one item per bit, trailing mark and gap + dst->reserve(4 + count * BITS_PER_PACKET + 2); + dst->item(HEADER_US, T_US); + dst->item(T_US, HEADER_US); + + // Each packet is a 1 bit, the byte MSB first, then a 0 bit; runs of equal bits become one mark or space + bool level = true; + uint32_t run = 0; + for (uint8_t p = 0; p < count; p++) { + const uint16_t packet = (1 << 9) | (bytes[p] << 1); + for (int8_t i = BITS_PER_PACKET - 1; i >= 0; i--) { + const bool bit = (packet >> i) & 1; + if (bit != level && run != 0) { + if (level) { + dst->mark(run * T_US); + } else { + dst->space(run * T_US); + } + run = 0; + } + level = bit; + run++; + } + } + // Every packet ends with a 0 bit, so the frame ends with a space, closed by a trailing mark + dst->space(run * T_US); + dst->mark(T_US); + dst->space(HEADER_US); +} + +optional IRA211Protocol::decode(RemoteReceiveData src) { + if (!src.expect_item(HEADER_US, T_US) || !src.expect_item(T_US, HEADER_US)) + return {}; + + // Read bits until the end-of-frame gap, checking packet framing and collecting bytes as they arrive + uint8_t bytes[MAX_PACKETS]{}; + uint8_t bits = 0; + while (src.is_valid()) { + const int32_t raw = src.peek(); + const bool is_mark = raw > 0; + const uint32_t duration = is_mark ? raw : -raw; + // A mark followed by the gap or the end of the data is the trailing mark, not a data bit + if (is_mark && (!src.is_valid(1) || src.peek(1) <= -static_cast(GAP_MIN_US))) + break; + if (!is_mark && duration >= GAP_MIN_US) + break; + const uint32_t run = (duration + T_US / 2) / T_US; + if (run == 0 || bits + run > MAX_BITS) + return {}; + for (uint32_t i = 0; i < run; i++, bits++) { + const uint8_t pos = bits % BITS_PER_PACKET; + if ((pos == 0 && !is_mark) || (pos == BITS_PER_PACKET - 1 && is_mark)) + return {}; + if (pos != 0 && pos != BITS_PER_PACKET - 1) { + uint8_t &byte = bytes[bits / BITS_PER_PACKET]; + byte = (byte << 1) | (is_mark ? 1 : 0); + } + } + src.advance(); + } + + const uint8_t count = bits / BITS_PER_PACKET; + if (bits % BITS_PER_PACKET != 0 || count < MIN_PACKETS || bytes[0] != DEVICE_ID) + return {}; + IRA211Data out{}; + out.command = static_cast(wire_xform(bytes[1])); + const optional layout = find_layout(out.command); + if (!layout.has_value() || packet_count(*layout) != count || checksum(bytes, count - 1) != bytes[count - 1]) { + ESP_LOGV(TAG, "Invalid frame (%u bits)", bits); + return {}; + } + + // A checksum-valid frame can still carry values outside what the thermostat accepts + uint8_t index = 2; + if (layout->fields & FIELD_TEMPERATURE) { + const uint8_t whole = wire_xform(bytes[index++]); + const uint8_t tenths = wire_xform(bytes[index++]); + const uint16_t half_degrees = whole * 2 + (tenths >= 5 ? 1 : 0); + if (half_degrees < MIN_HALF_DEGREES || half_degrees > MAX_HALF_DEGREES) + return {}; + out.half_degrees = static_cast(half_degrees); + } + if (layout->fields & FIELD_MODE) { + const uint8_t mode = wire_xform(bytes[index++]); + if (!is_known_mode(mode)) + return {}; + out.mode = static_cast(mode); + } + if (layout->fields & FIELD_FAN) { + const uint8_t fan = wire_xform(bytes[index++]); + if (!is_known_fan(fan)) + return {}; + out.fan = static_cast(fan); + } + return out; +} + +static const LogString *command_to_string(IRA211Command command) { + switch (command) { + case IRA211Command::IRA211_COMMAND_TEMP_UP: + return LOG_STR("TEMP_UP"); + case IRA211Command::IRA211_COMMAND_TEMP_DOWN: + return LOG_STR("TEMP_DOWN"); + case IRA211Command::IRA211_COMMAND_MODE: + return LOG_STR("MODE"); + case IRA211Command::IRA211_COMMAND_FAN: + return LOG_STR("FAN"); + case IRA211Command::IRA211_COMMAND_POWER: + return LOG_STR("POWER"); + case IRA211Command::IRA211_COMMAND_SYNC: + return LOG_STR("SYNC"); + default: + return LOG_STR("UNKNOWN"); + } +} + +static const LogString *mode_to_string(IRA211Mode mode) { + switch (mode) { + case IRA211Mode::IRA211_MODE_PROTECTION: + return LOG_STR("Protection"); + case IRA211Mode::IRA211_MODE_TIMER: + return LOG_STR("Timer"); + case IRA211Mode::IRA211_MODE_COMFORT: + return LOG_STR("Comfort"); + default: + return LOG_STR("Unknown"); + } +} + +static const LogString *fan_to_string(IRA211Fan fan) { + switch (fan) { + case IRA211Fan::IRA211_FAN_AUTO: + return LOG_STR("Auto"); + case IRA211Fan::IRA211_FAN_LOW: + return LOG_STR("1/3"); + case IRA211Fan::IRA211_FAN_MEDIUM: + return LOG_STR("2/3"); + case IRA211Fan::IRA211_FAN_HIGH: + return LOG_STR("3/3"); + default: + return LOG_STR("Unknown"); + } +} + +void IRA211Protocol::dump(const IRA211Data &data) { + const optional layout = find_layout(data.command); + const uint8_t fields = layout.has_value() ? layout->fields : 0; + const unsigned whole = data.half_degrees / 2; + const unsigned tenths = (data.half_degrees & 1) ? 5 : 0; + const auto *command = LOG_STR_ARG(command_to_string(data.command)); + if (fields == (FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN)) { + ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC, mode=%s, fan=%s", command, whole, tenths, + LOG_STR_ARG(mode_to_string(data.mode)), LOG_STR_ARG(fan_to_string(data.fan))); + } else if (fields == FIELD_TEMPERATURE) { + ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC", command, whole, tenths); + } else if (fields == FIELD_MODE) { + ESP_LOGI(TAG, "Received IRA211: command=%s, mode=%s", command, LOG_STR_ARG(mode_to_string(data.mode))); + } else if (fields == FIELD_FAN) { + ESP_LOGI(TAG, "Received IRA211: command=%s, fan=%s", command, LOG_STR_ARG(fan_to_string(data.fan))); + } else { + ESP_LOGI(TAG, "Received IRA211: command=%s", command); + } +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/ira211_protocol.h b/esphome/components/remote_base/ira211_protocol.h new file mode 100644 index 0000000000..205d09215a --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.h @@ -0,0 +1,65 @@ +#pragma once + +#include "remote_base.h" +#include "esphome/core/helpers.h" + +#include + +namespace esphome::remote_base { + +/* Siemens IRA211 thermostat remote: 38 kHz carrier, NRZ runs of 800 us bits after a fixed preamble. + A frame is 4, 5 or 7 ten-bit packets (start bit 1, 8 data bits, end bit 0): device ID, command, + the command's fields, then a checksum. Field bytes go over the wire as reverse_bits(~value). */ + +enum class IRA211Command : uint8_t { + IRA211_COMMAND_TEMP_UP = 1, + IRA211_COMMAND_TEMP_DOWN = 2, + IRA211_COMMAND_MODE = 4, + IRA211_COMMAND_FAN = 5, + IRA211_COMMAND_POWER = 6, + IRA211_COMMAND_SYNC = 7, +}; + +enum class IRA211Mode : uint8_t { + IRA211_MODE_PROTECTION = 0, + IRA211_MODE_TIMER = 85, + IRA211_MODE_COMFORT = 170, +}; + +enum class IRA211Fan : uint8_t { + IRA211_FAN_AUTO = 3, + IRA211_FAN_LOW = 12, + IRA211_FAN_MEDIUM = 48, + IRA211_FAN_HIGH = 192, +}; + +static constexpr float IRA211_MIN_TEMPERATURE = 5.0f; +static constexpr float IRA211_MAX_TEMPERATURE = 35.0f; + +struct IRA211Data { + IRA211Command command{IRA211Command::IRA211_COMMAND_SYNC}; + uint8_t half_degrees{40}; // target temperature in 0.5 C steps + IRA211Mode mode{IRA211Mode::IRA211_MODE_COMFORT}; + IRA211Fan fan{IRA211Fan::IRA211_FAN_AUTO}; + + float get_temperature() const { return this->half_degrees * 0.5f; } + /// Rounds to the nearest half degree and clamps to the 5 to 35 C range the thermostat accepts. + void set_temperature(float temperature) { + this->half_degrees = + static_cast(lroundf(clamp(temperature, IRA211_MIN_TEMPERATURE, IRA211_MAX_TEMPERATURE) * 2.0f)); + } + + /// Compares the command and only the fields that command carries. + bool operator==(const IRA211Data &rhs) const; +}; + +class IRA211Protocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const IRA211Data &data); + optional decode(RemoteReceiveData src); + void dump(const IRA211Data &data); +}; + +DECLARE_REMOTE_PROTOCOL(IRA211) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/jvc_protocol.cpp b/esphome/components/remote_base/jvc_protocol.cpp index 86a47e757d..6fbbf6ee47 100644 --- a/esphome/components/remote_base/jvc_protocol.cpp +++ b/esphome/components/remote_base/jvc_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.jvc"; +ESPHOME_LOG_TAG(TAG, "remote.jvc"); static constexpr uint8_t NBITS = 16; static constexpr uint32_t HEADER_HIGH_US = 8400; diff --git a/esphome/components/remote_base/jvc_protocol.h b/esphome/components/remote_base/jvc_protocol.h index 5911664fc3..58991fc8f4 100644 --- a/esphome/components/remote_base/jvc_protocol.h +++ b/esphome/components/remote_base/jvc_protocol.h @@ -21,15 +21,4 @@ class JVCProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(JVC) -template class JVCAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - JVCData data{}; - data.data = this->data_.value(x...); - JVCProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.cpp b/esphome/components/remote_base/keeloq_protocol.cpp index 8cc9de94b8..fff8eb897f 100644 --- a/esphome/components/remote_base/keeloq_protocol.cpp +++ b/esphome/components/remote_base/keeloq_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.keeloq"; +ESPHOME_LOG_TAG(TAG, "remote.keeloq"); static constexpr uint32_t BIT_TIME_US = 380; static constexpr uint8_t NBITS_PREAMBLE = 12; @@ -19,24 +19,29 @@ static constexpr uint8_t NBITS_SYNC_CNT = 16; static constexpr uint8_t NBITS_FIXED_DATA = NBITS_REPEAT + NBITS_VLOW + NBITS_BUTTONS + NBITS_SERIAL; static constexpr uint8_t NBITS_ENCRYPTED_DATA = NBITS_BUTTONS + NBITS_DISC + NBITS_SYNC_CNT; static constexpr uint8_t NBITS_DATA = NBITS_FIXED_DATA + NBITS_ENCRYPTED_DATA; +static constexpr uint8_t NBITS_SUFFIX_MAX = 16; +// A space this long is not part of the PWM: data spaces are at most 2 bit times, the guard is 39. +static constexpr uint32_t GAP_TIME_US = 4 * BIT_TIME_US; /* KeeLoq Protocol Coded using information from datasheet for Microchip HCS301 KeeLow Code Hopping Encoder -Encoder - Hopping code is generated at random. +Encoder - Hopping code is not generated. Send the encrypted field from the caller. +Optional suffix bits may follow the 66-bit HCS301 word (same PWM coding). Decoder - Hopping code is ignored and not checked when received. Serial number of -transmitter and nutton command is decoded. +transmitter and button command is decoded. Extra bits after the word are returned +as suffix / suffix_bits when present. */ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { uint32_t out_data = 0x0; - ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32, data.address, data.command, - data.encrypted); + ESP_LOGD(TAG, "Send Keeloq: address=%07" PRIx32 " command=%03x encrypted=%08" PRIx32 " suffix_bits=%u", data.address, + data.command, data.encrypted, data.suffix_bits); ESP_LOGV(TAG, "Send Keeloq: data bits (%d + %d)", NBITS_ENCRYPTED_DATA, NBITS_FIXED_DATA); // Preamble = '01' x 12 @@ -92,6 +97,17 @@ void KeeloqProtocol::encode(RemoteTransmitData *dst, const KeeloqData &data) { dst->mark(1 * BIT_TIME_US); dst->space(2 * BIT_TIME_US); + const uint8_t extra = data.suffix_bits > NBITS_SUFFIX_MAX ? NBITS_SUFFIX_MAX : data.suffix_bits; + for (uint8_t i = 0; i < extra; i++) { + if (data.suffix & (1 << i)) { + dst->mark(1 * BIT_TIME_US); + dst->space(2 * BIT_TIME_US); + } else { + dst->mark(2 * BIT_TIME_US); + dst->space(1 * BIT_TIME_US); + } + } + // Guard time at end of packet dst->space(39 * BIT_TIME_US); } @@ -103,10 +119,11 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { .command = 0, .repeat = false, .vlow = false, - + .suffix = 0, + .suffix_bits = 0, }; - if (src.size() != (NBITS_PREAMBLE + NBITS_DATA) * 2) { + if (src.size() < (NBITS_PREAMBLE + NBITS_DATA) * 2) { return {}; } @@ -182,11 +199,43 @@ optional KeeloqProtocol::decode(RemoteReceiveData src) { return {}; } + if (out.repeat) { + src.expect_space(2 * BIT_TIME_US); + } else { + src.expect_space(BIT_TIME_US); + } + + while (out.suffix_bits < NBITS_SUFFIX_MAX) { + bool one = false; + if (src.expect_mark(2 * BIT_TIME_US)) { + one = false; + } else if (src.expect_mark(BIT_TIME_US)) { + one = true; + } else { + break; + } + // The last bit's space is merged into the guard gap, or the capture ends at idle. + if (!src.expect_space(one ? 2 * BIT_TIME_US : BIT_TIME_US) && src.is_valid() && + !src.peek_space_at_least(GAP_TIME_US)) { + break; + } + if (one) { + out.suffix |= 1 << out.suffix_bits; + } + out.suffix_bits++; + } + // The suffix ends at a gap or at the end of the capture. PWM that goes on is not a suffix. + if (src.is_valid() && !src.peek_space_at_least(GAP_TIME_US)) { + out.suffix = 0; + out.suffix_bits = 0; + } + return out; } void KeeloqProtocol::dump(const KeeloqData &data) { - ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x", data.address, data.command); + ESP_LOGD(TAG, "Received Keeloq: address=0x%08" PRIx32 ", command=0x%02x, encrypted=0x%08" PRIx32 ", suffix=0x%04x/%u", + data.address, data.command, data.encrypted, data.suffix, data.suffix_bits); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/keeloq_protocol.h b/esphome/components/remote_base/keeloq_protocol.h index 335fbd164b..e391212e42 100644 --- a/esphome/components/remote_base/keeloq_protocol.h +++ b/esphome/components/remote_base/keeloq_protocol.h @@ -11,10 +11,11 @@ struct KeeloqData { uint8_t command; // Button Status S2-S1-S0-S3 bool repeat; // Repeated command bit bool vlow; // Battery status bit + uint16_t suffix{0}; // Optional bits after the 66-bit HCS301 word + uint8_t suffix_bits{0}; bool operator==(const KeeloqData &rhs) const { - // Treat 0x10 as a special, wildcard button press - // This allows us to match on just the address if wanted. + // 0x10 matches any command. The suffix is not compared: the binary sensor does not take it. if (address != rhs.address) { return false; } @@ -31,21 +32,4 @@ class KeeloqProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Keeloq) -template class KeeloqAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, address) - TEMPLATABLE_VALUE(uint32_t, encrypted) - TEMPLATABLE_VALUE(uint8_t, command) - TEMPLATABLE_VALUE(bool, vlow) - - void encode(RemoteTransmitData *dst, Ts... x) override { - KeeloqData data{}; - data.address = this->address_.value(x...); - data.encrypted = this->encrypted_.value(x...); - data.command = this->command_.value(x...); - data.vlow = this->vlow_.value(x...); - KeeloqProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/lg_protocol.cpp b/esphome/components/remote_base/lg_protocol.cpp index e450659b42..07f4b5a1dc 100644 --- a/esphome/components/remote_base/lg_protocol.cpp +++ b/esphome/components/remote_base/lg_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.lg"; +ESPHOME_LOG_TAG(TAG, "remote.lg"); static constexpr uint32_t HEADER_HIGH_US = 8000; static constexpr uint32_t HEADER_LOW_US = 4000; diff --git a/esphome/components/remote_base/lg_protocol.h b/esphome/components/remote_base/lg_protocol.h index 91dfbadb0c..41ca3a2b04 100644 --- a/esphome/components/remote_base/lg_protocol.h +++ b/esphome/components/remote_base/lg_protocol.h @@ -23,17 +23,4 @@ class LGProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(LG) -template class LGAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - LGData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - LGProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/magiquest_protocol.cpp b/esphome/components/remote_base/magiquest_protocol.cpp index f417827b38..71a6a5ace1 100644 --- a/esphome/components/remote_base/magiquest_protocol.cpp +++ b/esphome/components/remote_base/magiquest_protocol.cpp @@ -2,20 +2,33 @@ #include "esphome/core/log.h" /* Based on protocol analysis from - * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8cpp_source.html + * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8hpp_source.html */ namespace esphome::remote_base { -static const char *const TAG = "remote.magiquest"; +ESPHOME_LOG_TAG(TAG, "remote.magiquest"); static constexpr uint32_t MAGIQUEST_UNIT = 288; // us +// Half a unit is the widest window that still tells a one unit mark or space from a two unit one. +static constexpr uint32_t MAGIQUEST_TOLERANCE = MAGIQUEST_UNIT / 2; static constexpr uint32_t MAGIQUEST_ONE_MARK = 2 * MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ONE_SPACE = 2 * MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ZERO_MARK = MAGIQUEST_UNIT; static constexpr uint32_t MAGIQUEST_ZERO_SPACE = 3 * MAGIQUEST_UNIT; +static bool checksum_is_valid(uint32_t wand_id, uint32_t magnitude_and_checksum) { + // The frame's six payload bytes, which start at the wand id's top bit, must sum to zero. + const uint32_t id_bytes = wand_id << 1; + uint8_t sum = 0; + for (uint8_t shift = 0; shift < 32; shift += 8) { + sum += (id_bytes >> shift) + (magnitude_and_checksum >> shift); + } + return sum == 0; +} + void MagiQuestProtocol::encode(RemoteTransmitData *dst, const MagiQuestData &data) { + // Still the old frame layout, which the decoder no longer reads; changing it would alter what existing configs send. dst->reserve(101); // 2 start bits, 48 data bits, 1 stop bit dst->set_carrier_frequency(38000); @@ -41,41 +54,48 @@ void MagiQuestProtocol::encode(RemoteTransmitData *dst, const MagiQuestData &dat dst->mark(MAGIQUEST_UNIT); } optional MagiQuestProtocol::decode(RemoteReceiveData src) { - MagiQuestData data{ - .magnitude = 0, - .wand_id = 0, - }; - // Two start bits - if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE) || - !src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - return {}; + src.set_tolerance(MAGIQUEST_TOLERANCE, TOLERANCE_MODE_TIME); + + MagiQuestData data{}; + + // 8 bit header, always zero + for (uint8_t i = 0; i < 8; i++) { + if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { + return {}; + } } - for (uint32_t mask = 1 << 31; mask; mask >>= 1) { + // 31 bit wand id + for (uint32_t mask = 1 << 30; mask; mask >>= 1) { if (src.expect_item(MAGIQUEST_ONE_MARK, MAGIQUEST_ONE_SPACE)) { data.wand_id |= mask; - } else if (src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - data.wand_id &= ~mask; - } else { + } else if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { return {}; } } - for (uint16_t mask = 1 << 15; mask; mask >>= 1) { + // 9 bit magnitude, then 8 bit checksum + uint32_t magnitude_and_checksum = 0; + for (uint32_t mask = 1 << 16; mask > 1; mask >>= 1) { if (src.expect_item(MAGIQUEST_ONE_MARK, MAGIQUEST_ONE_SPACE)) { - data.magnitude |= mask; - } else if (src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { - data.magnitude &= ~mask; - } else { + magnitude_and_checksum |= mask; + } else if (!src.expect_item(MAGIQUEST_ZERO_MARK, MAGIQUEST_ZERO_SPACE)) { return {}; } } + // The last bit has no trailing space. A wrong or missing mark here is caught by the checksum. + if (src.expect_mark(MAGIQUEST_ONE_MARK)) { + magnitude_and_checksum |= 1; + } - src.expect_mark(MAGIQUEST_UNIT); + if (!checksum_is_valid(data.wand_id, magnitude_and_checksum)) { + return {}; + } + data.magnitude = magnitude_and_checksum >> 8; return data; } void MagiQuestProtocol::dump(const MagiQuestData &data) { - ESP_LOGI(TAG, "Received MagiQuest: wand_id=0x%08" PRIX32 ", magnitude=0x%04X", data.wand_id, data.magnitude); + ESP_LOGI(TAG, "Received MagiQuest: wand_id=0x%08" PRIX32 ", magnitude=%u", data.wand_id, data.magnitude); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/magiquest_protocol.h b/esphome/components/remote_base/magiquest_protocol.h index f0d2410fe2..4e88f06cd4 100644 --- a/esphome/components/remote_base/magiquest_protocol.h +++ b/esphome/components/remote_base/magiquest_protocol.h @@ -5,23 +5,24 @@ #include /* Based on protocol analysis from - * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8cpp_source.html + * https://arduino-irremote.github.io/Arduino-IRremote/ir__MagiQuest_8hpp_source.html */ namespace esphome::remote_base { +// Older decoders read the wand id 5 bits too early, so ids in older configs lack its 5 low bits. +static constexpr uint8_t MAGIQUEST_LEGACY_WAND_ID_SHIFT = 5; + struct MagiQuestData { uint16_t magnitude; uint32_t wand_id; + // Not symmetric: `this` is the decoded frame and `rhs` the configured match. A configured wand_id of 0 matches + // any wand. Magnitude is not compared; it appears to encode the cast type rather than a strength. The legacy + // match applies to every configured id, so an id below 2^26 also matches the 32 wands whose full id shifts to it. bool operator==(const MagiQuestData &rhs) const { - // Treat 0xffff as a special, wildcard magnitude - // In testing, the wand never produces this value, and this allows us to match - // on just the wand_id if wanted. - if (rhs.wand_id != this->wand_id) { - return false; - } - return (this->wand_id == 0xffff || rhs.wand_id == 0xffff || this->wand_id == rhs.wand_id); + return rhs.wand_id == 0 || rhs.wand_id == this->wand_id || + rhs.wand_id == (this->wand_id >> MAGIQUEST_LEGACY_WAND_ID_SHIFT); } }; @@ -34,17 +35,4 @@ class MagiQuestProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(MagiQuest) -template class MagiQuestAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, magnitude) - TEMPLATABLE_VALUE(uint32_t, wand_id) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MagiQuestData data{}; - data.magnitude = this->magnitude_.value(x...); - data.wand_id = this->wand_id_.value(x...); - MagiQuestProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/midea_protocol.cpp b/esphome/components/remote_base/midea_protocol.cpp index 6889c5d9b4..57aeda2e90 100644 --- a/esphome/components/remote_base/midea_protocol.cpp +++ b/esphome/components/remote_base/midea_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.midea"; +ESPHOME_LOG_TAG(TAG, "remote.midea"); static const int32_t TICK_US = 560; static const int32_t HEADER_MARK_US = 8 * TICK_US; diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 85bbef1cb1..572179605a 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -4,6 +4,7 @@ #include #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "remote_base.h" @@ -21,6 +22,9 @@ class MideaData { MideaData(const std::vector &data) { std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); } + MideaData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } uint8_t *data() { return this->data_.data(); } const uint8_t *data() const { return this->data_.data(); } @@ -74,14 +78,4 @@ class MideaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Midea) -template class MideaAction : public RemoteTransmitterActionBase { - TEMPLATABLE_VALUE(std::vector, code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MideaData data(this->code_.value(x...)); - data.finalize(); - MideaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/mirage_protocol.cpp b/esphome/components/remote_base/mirage_protocol.cpp index 380cfaecb2..099388f8ec 100644 --- a/esphome/components/remote_base/mirage_protocol.cpp +++ b/esphome/components/remote_base/mirage_protocol.cpp @@ -1,10 +1,13 @@ #include "mirage_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::remote_base { -static const char *const TAG = "remote.mirage"; +ESPHOME_LOG_TAG(TAG, "remote.mirage"); constexpr uint32_t HEADER_MARK_US = 8360; constexpr uint32_t HEADER_SPACE_US = 4248; @@ -16,16 +19,21 @@ constexpr unsigned int MIRAGE_IR_PACKET_BIT_SIZE = 120; // Max data bytes in packet (excluding checksum) constexpr size_t MIRAGE_MAX_DATA_BYTES = (MIRAGE_IR_PACKET_BIT_SIZE / 8); -void MirageProtocol::encode(RemoteTransmitData *dst, const MirageData &data) { +void MirageProtocol::encode(RemoteTransmitData *dst, const uint8_t *data, size_t len) { + // data may be a PROGMEM table, so log a RAM copy + uint8_t log_buf[MIRAGE_MAX_DATA_BYTES]; + const size_t log_len = std::min(len, MIRAGE_MAX_DATA_BYTES); + progmem_memcpy(log_buf, data, log_len); char hex_buf[format_hex_pretty_size(MIRAGE_MAX_DATA_BYTES)]; - ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, data.data.data(), data.data.size())); + ESP_LOGI(TAG, "Transmit Mirage: %s", format_hex_pretty_to(hex_buf, log_buf, log_len)); dst->set_carrier_frequency(38000); - dst->reserve(5 + ((data.data.size() + 1) * 2)); + dst->reserve(5 + ((len + 1) * 2)); dst->mark(HEADER_MARK_US); dst->space(HEADER_SPACE_US); dst->mark(BIT_MARK_US); uint8_t checksum = 0; - for (uint8_t item : data.data) { + for (size_t i = 0; i < len; i++) { + const uint8_t item = progmem_read_byte(data + i); this->encode_byte_(dst, item); checksum += (item >> 4) + (item & 0xF); } diff --git a/esphome/components/remote_base/mirage_protocol.h b/esphome/components/remote_base/mirage_protocol.h index a37fb93f4f..ae6ed0a8e9 100644 --- a/esphome/components/remote_base/mirage_protocol.h +++ b/esphome/components/remote_base/mirage_protocol.h @@ -13,7 +13,14 @@ struct MirageData { class MirageProtocol : public RemoteProtocol { public: - void encode(RemoteTransmitData *dst, const MirageData &data); + void encode(RemoteTransmitData *dst, const MirageData &data) { + this->encode(dst, data.data.data(), data.data.size()); + } + /// data may be a PROGMEM table + void encode(RemoteTransmitData *dst, const uint8_t *data, size_t len); + void encode(RemoteTransmitData *dst, const std::vector &data) { + this->encode(dst, data.data(), data.size()); + } optional decode(RemoteReceiveData src); void dump(const MirageData &data); @@ -23,15 +30,4 @@ class MirageProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Mirage) -template class MirageAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(std::vector, code) - - void encode(RemoteTransmitData *dst, Ts... x) override { - MirageData data{}; - data.data = this->code_.value(x...); - MirageProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nec_protocol.cpp b/esphome/components/remote_base/nec_protocol.cpp index e639248b4e..c4ba8c5ebc 100644 --- a/esphome/components/remote_base/nec_protocol.cpp +++ b/esphome/components/remote_base/nec_protocol.cpp @@ -1,44 +1,52 @@ #include "nec_protocol.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::remote_base { -static const char *const TAG = "remote.nec"; +ESPHOME_LOG_TAG(TAG, "remote.nec"); static constexpr uint32_t HEADER_HIGH_US = 9000; static constexpr uint32_t HEADER_LOW_US = 4500; static constexpr uint32_t BIT_HIGH_US = 560; static constexpr uint32_t BIT_ONE_LOW_US = 1690; static constexpr uint32_t BIT_ZERO_LOW_US = 560; +static constexpr uint32_t REPEAT_HEADER_LOW_US = HEADER_LOW_US / 2; +// A held button repeats every 108 ms: the first gap depends on the frame's own length, later ones follow a repeat frame +static constexpr uint32_t REPEAT_PERIOD_US = 108000; +static constexpr uint32_t REPEAT_FRAME_US = HEADER_HIGH_US + REPEAT_HEADER_LOW_US + BIT_HIGH_US; +static constexpr uint32_t REPEAT_GAP_US = REPEAT_PERIOD_US - REPEAT_FRAME_US; +// Transmitters without an RMT peripheral hold an interrupt lock for the whole send, about 108 ms per repeat +static constexpr uint16_t MAX_COMMAND_REPEATS = 10; void NECProtocol::encode(RemoteTransmitData *dst, const NECData &data) { ESP_LOGD(TAG, "Sending NEC: address=0x%04X, command=0x%04X command_repeats=%d", data.address, data.command, data.command_repeats); - dst->reserve(2 + 32 + 32 * data.command_repeats + 2); + // The schema bounds literal values; a lambda can produce anything, so clamp here too + const uint16_t repeats = clamp(data.command_repeats, 1, MAX_COMMAND_REPEATS); + // Frame: header pair, 32 bit pairs, stop mark. Each extra repeat: gap space, header pair, mark. + dst->reserve(2 + 64 + 1 + (repeats - 1) * 4); dst->set_carrier_frequency(38000); dst->item(HEADER_HIGH_US, HEADER_LOW_US); + uint32_t frame_us = HEADER_HIGH_US + HEADER_LOW_US + BIT_HIGH_US; - for (uint16_t mask = 1; mask; mask <<= 1) { - if (data.address & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } - } - - for (uint16_t repeats = 0; repeats < data.command_repeats; repeats++) { + for (uint16_t value : {data.address, data.command}) { for (uint16_t mask = 1; mask; mask <<= 1) { - if (data.command & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } + const uint32_t low_us = (value & mask) ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US; + dst->item(BIT_HIGH_US, low_us); + frame_us += BIT_HIGH_US + low_us; } } dst->mark(BIT_HIGH_US); + + for (uint16_t repeat = 1; repeat < repeats; repeat++) { + dst->space(repeat == 1 ? REPEAT_PERIOD_US - frame_us : REPEAT_GAP_US); + dst->item(HEADER_HIGH_US, REPEAT_HEADER_LOW_US); + dst->mark(BIT_HIGH_US); + } } optional NECProtocol::decode(RemoteReceiveData src) { NECData data{ @@ -69,26 +77,7 @@ optional NECProtocol::decode(RemoteReceiveData src) { } } - while (src.peek_item(BIT_HIGH_US, BIT_ONE_LOW_US) || src.peek_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - uint16_t command = 0; - for (uint16_t mask = 1; mask; mask <<= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - command |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - command &= ~mask; - } else { - return {}; - } - } - - // Make sure the extra/repeated data matches original command - if (command != data.command) { - return {}; - } - - data.command_repeats += 1; - } - + // Repeat frames arrive as separate captures (receiver idle is far below the repeat gap) and are not decoded src.expect_mark(BIT_HIGH_US); return data; } diff --git a/esphome/components/remote_base/nec_protocol.h b/esphome/components/remote_base/nec_protocol.h index 1337f7a8b3..849952c40f 100644 --- a/esphome/components/remote_base/nec_protocol.h +++ b/esphome/components/remote_base/nec_protocol.h @@ -21,19 +21,4 @@ class NECProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(NEC) -template class NECAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint16_t, command) - TEMPLATABLE_VALUE(uint16_t, command_repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - NECData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - data.command_repeats = this->command_repeats_.value(x...); - NECProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexa_protocol.cpp b/esphome/components/remote_base/nexa_protocol.cpp index e691a69598..2d0d2cf757 100644 --- a/esphome/components/remote_base/nexa_protocol.cpp +++ b/esphome/components/remote_base/nexa_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.nexa"; +ESPHOME_LOG_TAG(TAG, "remote.nexa"); static constexpr uint8_t NBITS = 32; static constexpr uint32_t HEADER_HIGH_US = 319; diff --git a/esphome/components/remote_base/nexa_protocol.h b/esphome/components/remote_base/nexa_protocol.h index ebf85387b0..b4d2ec4030 100644 --- a/esphome/components/remote_base/nexa_protocol.h +++ b/esphome/components/remote_base/nexa_protocol.h @@ -31,22 +31,4 @@ class NexaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Nexa) -template class NexaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, device) - TEMPLATABLE_VALUE(uint8_t, group) - TEMPLATABLE_VALUE(uint8_t, state) - TEMPLATABLE_VALUE(uint8_t, channel) - TEMPLATABLE_VALUE(uint8_t, level) - void encode(RemoteTransmitData *dst, Ts... x) override { - NexaData data{}; - data.device = this->device_.value(x...); - data.group = this->group_.value(x...); - data.state = this->state_.value(x...); - data.channel = this->channel_.value(x...); - data.level = this->level_.value(x...); - NexaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexus_protocol.cpp b/esphome/components/remote_base/nexus_protocol.cpp new file mode 100644 index 0000000000..6d7ddbb7eb --- /dev/null +++ b/esphome/components/remote_base/nexus_protocol.cpp @@ -0,0 +1,83 @@ +#include "nexus_protocol.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.nexus"); + +/** + * Example Nexus (Digoo) temperature & humidity sensor packet (36 data bits, each a mark and a space): + * 10000101 | 1 | 0 | 01 | 000010001010 | 1111 | 01000101 + * address (sensor ID) | battery | force update | channel | temperature | - | humidity + * + * The channel is sent 0-based and shown 1-based, temperature is 12 bit signed scaled by 10, + * `-`s are constant bits. + * + * Ref: https://manual.pilight.org/protocols/433.92/weather/nexus.html + */ + +static constexpr uint8_t NBITS = 36; + +static constexpr uint32_t SYNC_HIGH_US = 500; +static constexpr uint32_t SYNC_LOW_US = 3900; +static constexpr uint32_t BIT_HIGH_US = 500; +static constexpr uint32_t BIT_ZERO_LOW_US = 980; +static constexpr uint32_t BIT_ONE_LOW_US = 1950; + +void NexusProtocol::encode(RemoteTransmitData *dst, const NexusData &data) { + // A templated value may be out of range or NaN (an unavailable sensor); send 0 for NaN + const float temperature = std::isnan(data.temperature) ? 0.0f : std::clamp(data.temperature, -204.8f, 204.7f); + const auto t = static_cast(lroundf(temperature * 10.0f)); + const uint64_t packet = uint64_t(data.address) << 28 | uint64_t(data.battery_level) << 27 | + uint64_t(data.force_update) << 26 | uint64_t((data.channel - 1) & 0b11) << 24 | + uint64_t(t & 0x0FFF) << 12 | 0b1111 << 8 | data.humidity; + + dst->reserve(NBITS * 2 + 2); + for (int8_t i = NBITS - 1; i >= 0; i--) + dst->item(BIT_HIGH_US, (packet >> i) & 1 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + // The sync follows the bits, as the sensors send it: its mark ends the last space, its gap separates repeats + dst->item(SYNC_HIGH_US, SYNC_LOW_US); +} + +optional NexusProtocol::decode(RemoteReceiveData src) { + // A buffer may start with the previous frame's sync when the receiver did not split on it + src.expect_item(SYNC_HIGH_US, SYNC_LOW_US); + if (src.size() < NBITS * 2) + return {}; + + uint64_t packet = 0; + for (uint8_t i = 0; i < NBITS; i++) { + packet <<= 1; + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + packet |= 1; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return {}; + } + } + if (((packet >> 8) & 0xF) != 0xF) + return {}; + ESP_LOGV(TAG, "Nexus packet: 0x%09" PRIX64, packet); + + return NexusData{ + // The 12 bit field sits at bits 12-23; shift it to the top of an int16_t and back to sign extend. + .temperature = (static_cast((packet >> 8) & 0xFFF0) >> 4) / 10.0f, + .address = static_cast(packet >> 28), + .battery_level = static_cast((packet >> 27) & 1), + .force_update = static_cast((packet >> 26) & 1), + .channel = static_cast(((packet >> 24) & 0b11) + 1), + .humidity = static_cast(packet), + }; +} + +void NexusProtocol::dump(const NexusData &data) { + ESP_LOGI(TAG, + "Received Nexus: address=0x%02X, channel=%u, temperature=%.1f, humidity=%u, battery_level=%d, " + "force_update=%d", + data.address, data.channel, data.temperature, data.humidity, data.battery_level, data.force_update); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/nexus_protocol.h b/esphome/components/remote_base/nexus_protocol.h new file mode 100644 index 0000000000..de3f55777d --- /dev/null +++ b/esphome/components/remote_base/nexus_protocol.h @@ -0,0 +1,30 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct NexusData { + float temperature; // °C, sent as 12 bit signed tenths + uint8_t address; + bool battery_level; + bool force_update; + uint8_t channel; // sent 0-based, shown 1-based + uint8_t humidity; + + bool operator==(const NexusData &rhs) const { + // The binary sensor matches the transmitter, not the reading. + return channel == rhs.channel && address == rhs.address; + } +}; + +class NexusProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const NexusData &data); + optional decode(RemoteReceiveData src); + void dump(const NexusData &data); +}; + +DECLARE_REMOTE_PROTOCOL(Nexus) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/onkyori_protocol.cpp b/esphome/components/remote_base/onkyori_protocol.cpp new file mode 100644 index 0000000000..6290e5bc50 --- /dev/null +++ b/esphome/components/remote_base/onkyori_protocol.cpp @@ -0,0 +1,59 @@ +#include "onkyori_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.onkyori"); + +static constexpr uint8_t NBITS = 12; +static constexpr uint16_t MAX_DATA = (1U << NBITS) - 1; +static constexpr uint32_t HEADER_HIGH_US = 3000; +static constexpr uint32_t HEADER_LOW_US = 1000; +static constexpr uint32_t BIT_HIGH_US = 1000; +static constexpr uint32_t BIT_ONE_LOW_US = 2000; +static constexpr uint32_t BIT_ZERO_LOW_US = 1000; +static constexpr uint32_t TRAILER_HIGH_US = 1000; + +void OnkyoRIProtocol::encode(RemoteTransmitData *dst, const OnkyoRIData &data) { + if (data.data > MAX_DATA) { + ESP_LOGE(TAG, "Data 0x%04X does not fit in a 12-bit RI frame, not sending", data.data); + return; + } + + // RI is a baseband signal on a wire, so it has no carrier to modulate. + dst->set_carrier_frequency(0); + dst->reserve(2 + NBITS * 2u + 1); + + dst->item(HEADER_HIGH_US, HEADER_LOW_US); + + for (uint32_t mask = 1UL << (NBITS - 1); mask != 0; mask >>= 1) { + dst->item(BIT_HIGH_US, (data.data & mask) != 0 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + } + + dst->mark(TRAILER_HIGH_US); +} + +optional OnkyoRIProtocol::decode(RemoteReceiveData src) { + OnkyoRIData out{.data = 0}; + + if (!src.expect_item(HEADER_HIGH_US, HEADER_LOW_US)) + return {}; + + for (uint8_t i = 0; i < NBITS; i++) { + out.data <<= 1; + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + out.data |= 1; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return {}; + } + } + + if (!src.expect_mark(TRAILER_HIGH_US)) + return {}; + + return out; +} + +void OnkyoRIProtocol::dump(const OnkyoRIData &data) { ESP_LOGI(TAG, "Received OnkyoRI: data=0x%03X", data.data); } + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/onkyori_protocol.h b/esphome/components/remote_base/onkyori_protocol.h new file mode 100644 index 0000000000..55efb1e9a3 --- /dev/null +++ b/esphome/components/remote_base/onkyori_protocol.h @@ -0,0 +1,22 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct OnkyoRIData { + uint16_t data; + + bool operator==(const OnkyoRIData &rhs) const { return data == rhs.data; } +}; + +class OnkyoRIProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const OnkyoRIData &data); + optional decode(RemoteReceiveData src); + void dump(const OnkyoRIData &data); +}; + +DECLARE_REMOTE_PROTOCOL(OnkyoRI) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/panasonic_protocol.cpp b/esphome/components/remote_base/panasonic_protocol.cpp index b026213080..bedc9bca91 100644 --- a/esphome/components/remote_base/panasonic_protocol.cpp +++ b/esphome/components/remote_base/panasonic_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.panasonic"; +ESPHOME_LOG_TAG(TAG, "remote.panasonic"); static constexpr uint32_t HEADER_HIGH_US = 3502; static constexpr uint32_t HEADER_LOW_US = 1750; @@ -11,62 +11,72 @@ static constexpr uint32_t BIT_HIGH_US = 502; static constexpr uint32_t BIT_ZERO_LOW_US = 400; static constexpr uint32_t BIT_ONE_LOW_US = 1244; +static constexpr uint8_t NBITS_ADDRESS = 16; +static constexpr uint8_t NBITS_ADDRESS_2 = 8; +static constexpr uint8_t NBITS_COMMAND = 32; +static constexpr uint8_t NBITS_SHORT = NBITS_ADDRESS + NBITS_COMMAND; +static constexpr uint8_t NBITS_LONG = NBITS_SHORT + NBITS_ADDRESS_2; + +static void encode_bits(RemoteTransmitData *dst, uint32_t value, uint8_t nbits) { + for (uint32_t mask = 1UL << (nbits - 1); mask != 0; mask >>= 1) { + dst->item(BIT_HIGH_US, (value & mask) ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US); + } +} + +static bool decode_bits(RemoteReceiveData &src, uint32_t &value, uint8_t nbits) { + value = 0; + for (uint32_t mask = 1UL << (nbits - 1); mask != 0; mask >>= 1) { + if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { + value |= mask; + } else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { + return false; + } + } + return true; +} + void PanasonicProtocol::encode(RemoteTransmitData *dst, const PanasonicData &data) { - dst->reserve(100); + if (data.nbits != NBITS_SHORT && data.nbits != NBITS_LONG) { + ESP_LOGW(TAG, "Unsupported nbits %u, expected 48 or 56", data.nbits); + return; + } + // Header, one item per bit, trailing mark + dst->reserve(3 + 2 * data.nbits); + dst->set_carrier_frequency(data.carrier_frequency); dst->item(HEADER_HIGH_US, HEADER_LOW_US); - dst->set_carrier_frequency(35000); - - uint32_t mask; - for (mask = 1UL << 15; mask != 0; mask >>= 1) { - if (data.address & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } - } - - for (mask = 1UL << 31; mask != 0; mask >>= 1) { - if (data.command & mask) { - dst->item(BIT_HIGH_US, BIT_ONE_LOW_US); - } else { - dst->item(BIT_HIGH_US, BIT_ZERO_LOW_US); - } + encode_bits(dst, data.address, NBITS_ADDRESS); + if (data.nbits == NBITS_LONG) { + encode_bits(dst, data.address_2, NBITS_ADDRESS_2); } + encode_bits(dst, data.command, NBITS_COMMAND); dst->mark(BIT_HIGH_US); } + optional PanasonicProtocol::decode(RemoteReceiveData src) { - PanasonicData out{ - .address = 0, - .command = 0, - }; if (!src.expect_item(HEADER_HIGH_US, HEADER_LOW_US)) return {}; - uint32_t mask; - for (mask = 1UL << 15; mask != 0; mask >>= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - out.address |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - out.address &= ~mask; - } else { - return {}; - } - } + uint32_t address; + uint32_t word; + if (!decode_bits(src, address, NBITS_ADDRESS) || !decode_bits(src, word, NBITS_COMMAND)) + return {}; - for (mask = 1UL << 31; mask != 0; mask >>= 1) { - if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) { - out.command |= mask; - } else if (src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) { - out.command &= ~mask; - } else { - return {}; - } + PanasonicData out{}; + out.address = address; + uint32_t tail; + if (decode_bits(src, tail, NBITS_ADDRESS_2)) { + out.nbits = NBITS_LONG; + out.address_2 = word >> 24; + out.command = (word << 8) | tail; + } else { + out.command = word; } - return out; } + void PanasonicProtocol::dump(const PanasonicData &data) { - ESP_LOGI(TAG, "Received Panasonic: address=0x%04X, command=0x%08" PRIX32, data.address, data.command); + ESP_LOGI(TAG, "Received Panasonic: nbits=%u, address=0x%04X, address_2=0x%02X, command=0x%08" PRIX32, data.nbits, + data.address, data.address_2, data.command); } } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/panasonic_protocol.h b/esphome/components/remote_base/panasonic_protocol.h index 84df3c08b7..b560875982 100644 --- a/esphome/components/remote_base/panasonic_protocol.h +++ b/esphome/components/remote_base/panasonic_protocol.h @@ -8,10 +8,17 @@ namespace esphome::remote_base { struct PanasonicData { + // address and command stay first so positional initializers written for the 48-bit struct keep working. uint16_t address; uint32_t command; + uint8_t nbits{48}; // 48 or 56 + uint8_t address_2{0}; + // Transmit only: a receiver cannot measure the carrier, so it is not compared. + uint32_t carrier_frequency{35000}; - bool operator==(const PanasonicData &rhs) const { return address == rhs.address && command == rhs.command; } + bool operator==(const PanasonicData &rhs) const { + return address == rhs.address && address_2 == rhs.address_2 && command == rhs.command && nbits == rhs.nbits; + } }; class PanasonicProtocol : public RemoteProtocol { @@ -23,17 +30,4 @@ class PanasonicProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Panasonic) -template class PanasonicAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint32_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - PanasonicData data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - PanasonicProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pd_pioneer_protocol.cpp b/esphome/components/remote_base/pd_pioneer_protocol.cpp new file mode 100644 index 0000000000..72568be649 --- /dev/null +++ b/esphome/components/remote_base/pd_pioneer_protocol.cpp @@ -0,0 +1,57 @@ +#include "pd_pioneer_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pd_pioneer"); + +// Timings from captured Pronto codes (006D timebase, 26 us per word) +static constexpr uint32_t HEADER_MARK_US = 3120; +static constexpr uint32_t HEADER_SPACE_US = 1560; +static constexpr uint32_t BIT_MARK_US = 520; +static constexpr uint32_t BIT_ONE_SPACE_US = 1066; +static constexpr uint32_t BIT_ZERO_SPACE_US = 312; +static constexpr uint32_t FOOTER_MARK_US = 520; +static constexpr uint32_t FOOTER_SPACE_US = 10010; + +void PDPioneerProtocol::encode(RemoteTransmitData *dst, const PDPioneerData &src) { + dst->set_carrier_frequency(38000); + dst->reserve(2 + PDPioneerData::FRAME_SIZE * 8 * 2 + 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) { + for (uint8_t mask = 1; mask; mask <<= 1) + dst->item(BIT_MARK_US, (src[idx] & mask) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->item(FOOTER_MARK_US, FOOTER_SPACE_US); +} + +optional PDPioneerProtocol::decode(RemoteReceiveData src) { + PDPioneerData out; + if (!src.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) + return {}; + for (uint8_t idx = 0; idx < PDPioneerData::FRAME_SIZE; idx++) { + for (uint8_t mask = 1; mask; mask <<= 1) { + if (src.expect_item(BIT_MARK_US, BIT_ONE_SPACE_US)) { + out[idx] |= mask; + } else if (!src.expect_item(BIT_MARK_US, BIT_ZERO_SPACE_US)) { + return {}; + } + } + } + // The receiver's idle timeout usually ends the capture inside the footer space, so only the mark is required + if (!src.expect_mark(FOOTER_MARK_US)) + return {}; + if (!out.is_valid()) { + char buf[PDPioneerData::TO_STR_BUFFER_SIZE]; + ESP_LOGV(TAG, "Checksum mismatch: %s", out.to_str(buf)); + return {}; + } + return out; +} + +void PDPioneerProtocol::dump(const PDPioneerData &data) { + char buf[PDPioneerData::TO_STR_BUFFER_SIZE]; + ESP_LOGI(TAG, "Received PD-Pioneer: %s", data.to_str(buf)); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pd_pioneer_protocol.h b/esphome/components/remote_base/pd_pioneer_protocol.h new file mode 100644 index 0000000000..b324218f14 --- /dev/null +++ b/esphome/components/remote_base/pd_pioneer_protocol.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PDPioneerData { + public: + static constexpr uint8_t FRAME_SIZE = 14; + static constexpr uint8_t OFFSET_CS = 13; + + PDPioneerData() = default; + PDPioneerData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PDPioneerData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PDPioneerData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } + + uint8_t *data() { return this->data_.data(); } + const uint8_t *data() const { return this->data_.data(); } + + // Position 3 tells the two bursts of a command apart; the odd burst's checksum carries an extra 15 + bool is_odd_burst() const { return this->data_[3] == 0x02; } + bool is_even_burst() const { return this->data_[3] == 0x01; } + + bool is_valid() const { + return (this->is_odd_burst() || this->is_even_burst()) && this->data_[OFFSET_CS] == this->checksum_(); + } + + void finalize() { this->data_[OFFSET_CS] = this->checksum_(); } + + static constexpr size_t TO_STR_BUFFER_SIZE = format_hex_pretty_size(FRAME_SIZE); + const char *to_str(char *buffer) const { + format_hex_pretty_to(buffer, TO_STR_BUFFER_SIZE, this->data_.data(), this->data_.size(), '.'); + return buffer; + } + + bool operator==(const PDPioneerData &rhs) const { return this->data_ == rhs.data_; } + + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + uint8_t checksum_() const { + uint8_t cs = this->is_odd_burst() ? 15 : 0; + for (uint8_t idx = 0; idx < OFFSET_CS; idx++) + cs += this->data_[idx]; + return cs; + } + + std::array data_{}; +}; + +class PDPioneerProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PDPioneerData &src); + optional decode(RemoteReceiveData src); + void dump(const PDPioneerData &data); +}; + +DECLARE_REMOTE_PROTOCOL(PDPioneer) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_protocol.cpp b/esphome/components/remote_base/pioneer_protocol.cpp index f4d6aa4026..20603848f1 100644 --- a/esphome/components/remote_base/pioneer_protocol.cpp +++ b/esphome/components/remote_base/pioneer_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.pioneer"; +ESPHOME_LOG_TAG(TAG, "remote.pioneer"); static constexpr uint32_t HEADER_HIGH_US = 9000; static constexpr uint32_t HEADER_LOW_US = 4500; diff --git a/esphome/components/remote_base/pioneer_protocol.h b/esphome/components/remote_base/pioneer_protocol.h index d02bd3451f..b3d71d7ac9 100644 --- a/esphome/components/remote_base/pioneer_protocol.h +++ b/esphome/components/remote_base/pioneer_protocol.h @@ -20,17 +20,4 @@ class PioneerProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Pioneer) -template class PioneerAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, rc_code_1) - TEMPLATABLE_VALUE(uint16_t, rc_code_2) - - void encode(RemoteTransmitData *dst, Ts... x) override { - PioneerData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - PioneerProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.cpp b/esphome/components/remote_base/pioneer_wyt_protocol.cpp new file mode 100644 index 0000000000..0c7d6c7ec9 --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.cpp @@ -0,0 +1,30 @@ +#include "pioneer_wyt_protocol.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.pioneer_wyt"); + +// The timings and frame header are shared with tcl112 +static constexpr uint32_t HEADER_MARK_US = 3100; +static constexpr uint32_t HEADER_SPACE_US = 1650; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t BIT_ONE_SPACE_US = 1100; +static constexpr uint32_t BIT_ZERO_SPACE_US = 350; + +void PioneerWytProtocol::encode(RemoteTransmitData *dst, const PioneerWytData &data) { + char hex_buf[format_hex_pretty_size(PioneerWytData::SIZE)]; + ESP_LOGD(TAG, "Transmit PioneerWyt: %s", format_hex_pretty_to(hex_buf, data.data(), data.size())); + dst->set_carrier_frequency(38000); + // header mark and space, a mark and space per bit, then the trailing mark + dst->reserve(dst->get_data().size() + 3 + PioneerWytData::SIZE * 8 * 2); + dst->item(HEADER_MARK_US, HEADER_SPACE_US); + for (uint8_t i = 0; i < data.size(); i++) { + for (uint8_t b = 0; b < 8; b++) + dst->item(BIT_MARK_US, (data[i] & (1 << b)) ? BIT_ONE_SPACE_US : BIT_ZERO_SPACE_US); + } + dst->mark(BIT_MARK_US); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pioneer_wyt_protocol.h b/esphome/components/remote_base/pioneer_wyt_protocol.h new file mode 100644 index 0000000000..9946d4230b --- /dev/null +++ b/esphome/components/remote_base/pioneer_wyt_protocol.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" +#include "remote_base.h" + +namespace esphome::remote_base { + +class PioneerWytData { + public: + static constexpr uint8_t SIZE = 14; + enum PioneerWytDataType : uint8_t { + PIONEER_WYT_TYPE_GENERAL = 0x01, + PIONEER_WYT_TYPE_FAN = 0x02, + }; + + // Make default + PioneerWytData() {} + // Make from initializer_list + PioneerWytData(std::initializer_list data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + // Make from vector + PioneerWytData(const std::vector &data) { + std::copy_n(data.begin(), std::min(data.size(), this->data_.size()), this->data_.begin()); + } + PioneerWytData(const uint8_t *data, size_t len) { + progmem_memcpy(this->data_.data(), data, std::min(len, this->data_.size())); + } + + uint8_t *data() { return this->data_.data(); } + const uint8_t *data() const { return this->data_.data(); } + uint8_t size() const { return this->data_.size(); } + // FAN frames add 0x0F to the byte sum + void finalize() { + this->data_[OFFSET_CS] = + sum_bytes(this->data_.data(), OFFSET_CS, this->type() == PIONEER_WYT_TYPE_FAN ? 0x0F : 0x00); + } + PioneerWytDataType type() const { return static_cast(this->data_[3]); } + uint8_t &operator[](size_t idx) { return this->data_[idx]; } + const uint8_t &operator[](size_t idx) const { return this->data_[idx]; } + + protected: + static constexpr uint8_t OFFSET_CS = SIZE - 1; + std::array data_{}; +}; + +class PioneerWytProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const PioneerWytData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/pronto_protocol.cpp b/esphome/components/remote_base/pronto_protocol.cpp index dc128d4622..e47ba89291 100644 --- a/esphome/components/remote_base/pronto_protocol.cpp +++ b/esphome/components/remote_base/pronto_protocol.cpp @@ -37,7 +37,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.pronto"; +ESPHOME_LOG_TAG(TAG, "remote.pronto"); bool ProntoData::operator==(const ProntoData &rhs) const { std::vector data1 = encode_pronto(data); diff --git a/esphome/components/remote_base/pronto_protocol.h b/esphome/components/remote_base/pronto_protocol.h index bfd04c5cd9..4119b87f28 100644 --- a/esphome/components/remote_base/pronto_protocol.h +++ b/esphome/components/remote_base/pronto_protocol.h @@ -37,17 +37,4 @@ class ProntoProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Pronto) -template class ProntoAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(std::string, data) - TEMPLATABLE_VALUE(int, delta) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ProntoData data{}; - data.data = this->data_.value(x...); - data.delta = this->delta_.value(x...); - ProntoProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/raw_protocol.cpp b/esphome/components/remote_base/raw_protocol.cpp index 02c2916849..134204aa2a 100644 --- a/esphome/components/remote_base/raw_protocol.cpp +++ b/esphome/components/remote_base/raw_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.raw"; +ESPHOME_LOG_TAG(TAG, "remote.raw"); bool RawDumper::dump(RemoteReceiveData src) { char buffer[256]; diff --git a/esphome/components/remote_base/rc5_protocol.cpp b/esphome/components/remote_base/rc5_protocol.cpp index fd136a4e6d..cec747eb48 100644 --- a/esphome/components/remote_base/rc5_protocol.cpp +++ b/esphome/components/remote_base/rc5_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.rc5"; +ESPHOME_LOG_TAG(TAG, "remote.rc5"); static constexpr uint32_t BIT_TIME_US = 889; static constexpr uint8_t NBITS = 14; diff --git a/esphome/components/remote_base/rc5_protocol.h b/esphome/components/remote_base/rc5_protocol.h index f6f0f33c6e..d720fb11cc 100644 --- a/esphome/components/remote_base/rc5_protocol.h +++ b/esphome/components/remote_base/rc5_protocol.h @@ -21,17 +21,4 @@ class RC5Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(RC5) -template class RC5Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - RC5Data data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - RC5Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5x_protocol.cpp b/esphome/components/remote_base/rc5x_protocol.cpp new file mode 100644 index 0000000000..501766f0aa --- /dev/null +++ b/esphome/components/remote_base/rc5x_protocol.cpp @@ -0,0 +1,124 @@ +#include "rc5x_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +ESPHOME_LOG_TAG(TAG, "remote.rc5x"); + +static constexpr uint32_t HALF_BIT_US = 889; // a Manchester bit is two half-bits +// Start, field and toggle bits plus the address, then a pause of 4 half-bits, then command and extension +static constexpr uint8_t RC5_TOGGLE_BIT = 1; +static constexpr uint8_t RC5_ADDRESS_BITS = 5; +static constexpr uint8_t FIRST_PART_NBITS = 8; +static constexpr uint8_t RC5_COMMAND_BITS = 6; +static constexpr uint8_t RC5_EXT_BITS = 6; +static constexpr uint8_t NBITS = 20; +static constexpr uint8_t PAUSE_HALFBITS = 4; +// Half-bits in a frame, counting the pause; the pause starts after the first part's half-bits +static constexpr uint8_t NFRAMEHALFBITS = NBITS * 2 + PAUSE_HALFBITS; +static constexpr uint8_t PAUSE_START = FIRST_PART_NBITS * 2; + +void RC5XProtocol::encode(RemoteTransmitData *dst, const RC5XData &data) { + static bool toggle = false; + dst->set_carrier_frequency(36000); + dst->reserve(NFRAMEHALFBITS); + // Start bit, then the field bit, which is clear for commands 64 to 127 + const uint8_t command = data.command & 0x7F; // bit 6 travels in the field bit, higher bits have no place + const uint32_t start_field = command >= 64 ? 0b10 : 0b11; + const uint32_t out_data = start_field << (RC5_TOGGLE_BIT + RC5_ADDRESS_BITS + RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(toggle) << (RC5_ADDRESS_BITS + RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(data.address & 0x1F) << (RC5_COMMAND_BITS + RC5_EXT_BITS) | + uint32_t(command & 0x3F) << RC5_EXT_BITS | (data.extension & 0x3F); + + for (uint8_t bit = NBITS; bit-- > 0;) { + if (bit == NBITS - 1 - FIRST_PART_NBITS) { + for (uint8_t i = 0; i < PAUSE_HALFBITS; i++) + dst->space(HALF_BIT_US); + } + if (out_data & (1UL << bit)) { + dst->space(HALF_BIT_US); + dst->mark(HALF_BIT_US); + } else { + dst->mark(HALF_BIT_US); + dst->space(HALF_BIT_US); + } + } + toggle = !toggle; +} + +optional RC5XProtocol::decode(RemoteReceiveData src) { + // Expand the runs into half-bit levels (true = mark), as the RC5 decoder does. Each run is one + // half-bit (HALF_BIT_US) or two, except the pause, which is PAUSE_HALFBITS half-bits at the idle + // level plus up to one merged half on each side (the end of bit 12 and the start of bit 11). + // + // halfbits[0] is reserved for the leading half-bit, which is always dropped: S1 is 1, so its + // first half sits at the idle level and merges into the pre-frame idle. + bool halfbits[NFRAMEHALFBITS + 2]; + uint8_t n = 1; + bool pause_seen = false; + for (uint32_t i = 0; n <= NFRAMEHALFBITS && src.is_valid(i); i++) { + if (src.peek_mark(HALF_BIT_US, i)) { + halfbits[n++] = true; + } else if (src.peek_space(HALF_BIT_US, i)) { + halfbits[n++] = false; + } else if (src.peek_mark(2 * HALF_BIT_US, i)) { + halfbits[n++] = true; + halfbits[n++] = true; + } else if (src.peek_space(2 * HALF_BIT_US, i)) { + halfbits[n++] = false; + halfbits[n++] = false; + } else { + // The pause must start right after bit 12, or one half earlier when bit 12 ends idle + if (pause_seen || (n != PAUSE_START && n != PAUSE_START - 1)) + break; + const int32_t value = src.peek(i); + const bool level = value > 0; + const uint32_t halves = (static_cast(value > 0 ? value : -value) + HALF_BIT_US / 2) / HALF_BIT_US; + if (level == halfbits[1] || halves < PAUSE_HALFBITS || halves > PAUSE_HALFBITS + 2) + break; + for (uint32_t h = 0; h < halves; h++) + halfbits[n++] = level; + // It may also run one half into bit 11, but must cover the whole pause + if (n != PAUSE_START + PAUSE_HALFBITS && n != PAUSE_START + PAUSE_HALFBITS + 1) + return {}; + pause_seen = true; + } + } + + // A full frame, or one half short when the last bit ends idle and its trailing half is dropped. + // A dropped edge half is the inverse of its partner, since a Manchester bit always changes mid-bit. + if (!pause_seen || (n != NFRAMEHALFBITS && n != NFRAMEHALFBITS - 1)) { + return {}; + } + halfbits[0] = !halfbits[1]; + if (n == NFRAMEHALFBITS - 1) { + halfbits[n] = !halfbits[n - 1]; + } + + const bool carrier = halfbits[1]; + uint32_t bits = 0; + for (uint8_t i = 0; i < NBITS; i++) { + const uint8_t h = 2 * i + (i < FIRST_PART_NBITS ? 0 : PAUSE_HALFBITS); + const bool first = halfbits[h]; + const bool second = halfbits[h + 1]; + if (first == second) { + return {}; // no midpoint transition -> not a valid Manchester bit + } + bits = (bits << 1) | (second == carrier ? 1 : 0); + } + + // [18 field] [17 toggle] [16-12 address] [11-6 command] [5-0 extension] + const bool field_bit = bits & (1UL << (NBITS - 2)); // S2: the inverted 7th command bit + return RC5XData{ + .address = static_cast((bits >> (RC5_COMMAND_BITS + RC5_EXT_BITS)) & 0x1F), + .command = static_cast(((bits >> RC5_EXT_BITS) & 0x3F) | (field_bit ? 0 : 0x40)), + .extension = static_cast(bits & 0x3F), + }; +} + +void RC5XProtocol::dump(const RC5XData &data) { + ESP_LOGI(TAG, "Received RC5X (Marantz): address=0x%02X, command=0x%02X, extension=0x%02X", data.address, data.command, + data.extension); +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc5x_protocol.h b/esphome/components/remote_base/rc5x_protocol.h new file mode 100644 index 0000000000..1d1b2c003d --- /dev/null +++ b/esphome/components/remote_base/rc5x_protocol.h @@ -0,0 +1,31 @@ +#pragma once + +#include "remote_base.h" + +/* + * Marantz RC5 extended protocol (RC5x) + * See https://arduino-irremote.github.io/Arduino-IRremote/ir__RC5__RC6_8hpp_source.html + */ + +namespace esphome::remote_base { + +struct RC5XData { + uint8_t address; + uint8_t command; + uint8_t extension; + + bool operator==(const RC5XData &rhs) const { + return address == rhs.address && command == rhs.command && extension == rhs.extension; + } +}; + +class RC5XProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const RC5XData &data); + optional decode(RemoteReceiveData src); + void dump(const RC5XData &data); +}; + +DECLARE_REMOTE_PROTOCOL(RC5X) + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc6_protocol.cpp b/esphome/components/remote_base/rc6_protocol.cpp index ddd9e53258..067b8b812e 100644 --- a/esphome/components/remote_base/rc6_protocol.cpp +++ b/esphome/components/remote_base/rc6_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const RC6_TAG = "remote.rc6"; +ESPHOME_LOG_TAG(RC6_TAG, "remote.rc6"); static constexpr uint16_t RC6_FREQ = 36000; static constexpr uint16_t RC6_UNIT = 444; diff --git a/esphome/components/remote_base/rc6_protocol.h b/esphome/components/remote_base/rc6_protocol.h index c4a2e8529b..cdeb8dfec7 100644 --- a/esphome/components/remote_base/rc6_protocol.h +++ b/esphome/components/remote_base/rc6_protocol.h @@ -22,23 +22,4 @@ class RC6Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(RC6) -template class RC6Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, address) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) { - RC6Data data{}; - data.mode = 0; - data.toggle = this->toggle_; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - RC6Protocol().encode(dst, data); - this->toggle_ = !this->toggle_; - } - - protected: - uint8_t toggle_{0}; -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/rc_switch_protocol.cpp b/esphome/components/remote_base/rc_switch_protocol.cpp index de16c55cb0..a469cf2d45 100644 --- a/esphome/components/remote_base/rc_switch_protocol.cpp +++ b/esphome/components/remote_base/rc_switch_protocol.cpp @@ -6,15 +6,19 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.rc_switch"; +ESPHOME_LOG_TAG(TAG, "remote.rc_switch"); RCSwitchBase rc_switch_protocol(uint8_t index) { - RCSwitchBase protocol; // entry 0 is the all-zero protocol, so an out of range index from a lambda transmits nothing if (index >= std::size(RC_SWITCH_PROTOCOLS)) index = 0; - progmem_memcpy(&protocol, &RC_SWITCH_PROTOCOLS[index], sizeof(protocol)); - return protocol; + return rc_switch_protocol_copy(&RC_SWITCH_PROTOCOLS[index]); +} + +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol) { + RCSwitchBase copy; + progmem_memcpy(©, protocol, sizeof(copy)); + return copy; } void RCSwitchBase::one(RemoteTransmitData *dst) const { @@ -232,7 +236,7 @@ uint64_t decode_binary_string_mask(const std::string &data) { bool RCSwitchRawReceiver::matches(RemoteReceiveData src) { uint64_t decoded_code; uint8_t decoded_nbits; - if (!this->protocol_.decode(src, &decoded_code, &decoded_nbits)) + if (!this->protocol_->decode(src, &decoded_code, &decoded_nbits)) return false; return decoded_nbits == this->nbits_ && (decoded_code & this->mask_) == (this->code_ & this->mask_); diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index 9ccea4d15a..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" @@ -66,6 +68,9 @@ class RCSwitchBase { uint32_t one_high_{}; uint32_t one_low_{}; uint32_t inverted_{}; // bool widened so every field is a word: the table is read from flash + + // A bool here would still pad to 28 bytes, so the size check below alone would not catch it. + static_assert(std::is_same_v, "inverted_ must stay a word for flash reads"); }; // Constant-initialized and kept in flash on every platform. The decoder reads entries in place @@ -87,6 +92,9 @@ inline constexpr RCSwitchBase RC_SWITCH_PROTOCOLS[] PROGMEM = { /// RAM copy of RC_SWITCH_PROTOCOLS[index] (0 when out of range) for the transmit actions and the dumper, made with /// progmem_memcpy so no byte load ever touches the flash table on ESP8266 RCSwitchBase rc_switch_protocol(uint8_t index); +/// RAM copy of a protocol stored in flash, made with progmem_memcpy (own name: `rc_switch_protocol(0)` stays +/// unambiguous) +RCSwitchBase rc_switch_protocol_copy(const RCSwitchBase *protocol); uint64_t decode_binary_string(const std::string &data); @@ -200,7 +208,8 @@ template class RCSwitchTypeDAction : public RemoteTransmitterAct class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { public: - void set_protocol(const RCSwitchBase &a_protocol) { this->protocol_ = a_protocol; } + /// `protocol` must outlive the receiver: a RC_SWITCH_PROTOCOLS entry or a codegen flash table. + void set_protocol(const RCSwitchBase *protocol) { this->protocol_ = protocol; } void set_code(uint64_t code) { this->code_ = code; } void set_code(const std::string &code) { this->code_ = decode_binary_string(code); @@ -228,7 +237,7 @@ class RCSwitchRawReceiver : public RemoteReceiverBinarySensorBase { protected: bool matches(RemoteReceiveData src) override; - RCSwitchBase protocol_; + const RCSwitchBase *protocol_{nullptr}; // in flash; decoded in place (word-only fields) uint64_t code_; uint64_t mask_{0xFFFFFFFFFFFFFFFF}; uint8_t nbits_; diff --git a/esphome/components/remote_base/remote_base.cpp b/esphome/components/remote_base/remote_base.cpp index 5d1bba16b6..3c5291ffb2 100644 --- a/esphome/components/remote_base/remote_base.cpp +++ b/esphome/components/remote_base/remote_base.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote_base"; +ESPHOME_LOG_TAG(TAG, "remote_base"); /* RemoteReceiveData */ @@ -182,7 +182,21 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) { /* RemoteTransmitterBase */ -void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) { +RemoteTransmitterBase::TransmitCall RemoteTransmitterBase::transmit() { + this->temp_.reset(); + return TransmitCall(this, this->take_seq_()); +} + +void RemoteTransmitterBase::transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, + uint32_t send_wait) { + auto call = this->transmit(); + encode(call.get_data(), data); + call.set_send_times(send_times); + call.set_send_wait(send_wait); + call.perform(); +} + +void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, [[maybe_unused]] uint16_t seq) { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE const auto &vec = this->temp_.get_data(); char buffer[256]; @@ -213,6 +227,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 67e5799bca..783343c5a2 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -143,6 +143,14 @@ class RemoteRMTChannel { #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 +class RemoteTransmitterBase; + +#ifdef USE_IR_RF_TRANSMIT_COMPLETE +/// Defined by ir_rf_base: answers the API request waiting on the entity that submitted seq; +/// sent is false when the platform never put the frame on the wire. +/// One function for the whole build instead of a callback list on every transmitter. +void ir_rf_transmit_complete(RemoteTransmitterBase *transmitter, uint16_t seq, bool sent); +#endif // Protocol shapes, checked where a protocol is used so a missing method fails at the use site // instead of deep inside a template body. Receive-only protocols such as RCSwitchBase decode // without encoding. @@ -164,35 +172,81 @@ class RemoteTransmitterBase : public RemoteComponentBase { RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {} class TransmitCall { public: - explicit TransmitCall(RemoteTransmitterBase *parent) : parent_(parent) {} + TransmitCall(RemoteTransmitterBase *parent, uint16_t seq) : parent_(parent), seq_(seq) {} RemoteTransmitData *get_data() { return &this->parent_->temp_; } void set_send_times(uint32_t send_times) { send_times_ = send_times; } void set_send_wait(uint32_t send_wait) { send_wait_ = send_wait; } - void perform() { this->parent_->send_(this->send_times_, this->send_wait_); } + /// Identifies this transmission in the completion hook + uint16_t get_seq() const { return this->seq_; } + void perform() { this->parent_->send_(this->send_times_, this->send_wait_, this->seq_); } + + protected: + RemoteTransmitterBase *parent_; + uint32_t send_times_{1}; + uint32_t send_wait_{0}; + uint16_t seq_; + }; + + TransmitCall transmit(); + template + void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + this->transmit_encoded_(&encode_thunk, &data, send_times, send_wait); + } + /// Protocol data filled field by field, then sent by perform(); the call a generated transmit action builds. + template class ProtocolCall { + public: + explicit ProtocolCall(RemoteTransmitterBase *parent) : parent_(parent) {} + void set_send_times(uint32_t send_times) { this->send_times_ = send_times; } + void set_send_wait(uint32_t send_wait) { this->send_wait_ = send_wait; } + void perform() { this->parent_->template transmit(this->data, this->send_times_, this->send_wait_); } + + typename Protocol::ProtocolData data{}; protected: RemoteTransmitterBase *parent_; uint32_t send_times_{1}; uint32_t send_wait_{0}; }; - - TransmitCall transmit() { - this->temp_.reset(); - return TransmitCall(this); + template ProtocolCall protocol_call() { + return ProtocolCall(this); } - 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); - call.set_send_wait(send_wait); - call.perform(); + /// A transmit call whose encode() runs one of Protocol's encoders on its data, for a payload + /// the protocol encodes straight from a pointer and length; the call a generated action builds. + template class EncodeCall : public TransmitCall { + public: + explicit EncodeCall(TransmitCall call) : TransmitCall(call) {} + template void encode(const Args &...args) { Protocol().encode(this->get_data(), args...); } + }; + template EncodeCall encode_call() { + return EncodeCall(this->transmit()); } protected: - void send_(uint32_t send_times, uint32_t send_wait); + using EncodeFn = void (*)(RemoteTransmitData *dst, const void *data); + // One out-of-line send path, so each transmit

() call site only fills its data. + void transmit_encoded_(EncodeFn encode, const void *data, uint32_t send_times, uint32_t send_wait); + template static void encode_thunk(RemoteTransmitData *dst, const void *data) { + Protocol().encode(dst, *static_cast(data)); + } + void send_(uint32_t send_times, uint32_t send_wait, uint16_t seq); virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0; - void send_single_() { this->send_(1, 0); } + /// Platforms that report completion later wait out the previous frame here, before send_() + /// assigns the next seq, so that completion carries the seq it belongs to + virtual void flush_pending_completion() {} + void send_single_() { this->send_(1, 0, this->take_seq_()); } +#ifdef USE_IR_RF_TRANSMIT_COMPLETE + /// Reports the frame handed to the platform last, after its final repeat and before the + /// on_complete trigger; a seq only has to be unique within the 30 s reply window + void notify_complete_(bool sent) { ir_rf_transmit_complete(this, this->current_seq_, sent); } + uint16_t take_seq_() { return ++this->next_seq_; } + + uint16_t next_seq_{0}; + uint16_t current_seq_{0}; +#else + // seq tracking only exists for the API completion reply + void notify_complete_(bool /*sent*/) {} + static uint16_t take_seq_() { return 0; } +#endif /// Use same vector for all transmits, avoids many allocations RemoteTransmitData temp_; diff --git a/esphome/components/remote_base/roomba_protocol.cpp b/esphome/components/remote_base/roomba_protocol.cpp index 8053792a60..bd40a7a544 100644 --- a/esphome/components/remote_base/roomba_protocol.cpp +++ b/esphome/components/remote_base/roomba_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.roomba"; +ESPHOME_LOG_TAG(TAG, "remote.roomba"); static constexpr uint8_t NBITS = 8; static constexpr uint32_t BIT_ONE_HIGH_US = 3000; diff --git a/esphome/components/remote_base/roomba_protocol.h b/esphome/components/remote_base/roomba_protocol.h index 8db025f812..a8fab14580 100644 --- a/esphome/components/remote_base/roomba_protocol.h +++ b/esphome/components/remote_base/roomba_protocol.h @@ -19,15 +19,4 @@ class RoombaProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Roomba) -template class RoombaAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, data) - - void encode(RemoteTransmitData *dst, Ts... x) override { - RoombaData data{}; - data.data = this->data_.value(x...); - RoombaProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung36_protocol.cpp b/esphome/components/remote_base/samsung36_protocol.cpp index ded8c71aa3..c59ccf0106 100644 --- a/esphome/components/remote_base/samsung36_protocol.cpp +++ b/esphome/components/remote_base/samsung36_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.samsung36"; +ESPHOME_LOG_TAG(TAG, "remote.samsung36"); static constexpr uint8_t NBITS = 78; diff --git a/esphome/components/remote_base/samsung36_protocol.h b/esphome/components/remote_base/samsung36_protocol.h index df4e1af8d8..c70a98b112 100644 --- a/esphome/components/remote_base/samsung36_protocol.h +++ b/esphome/components/remote_base/samsung36_protocol.h @@ -23,17 +23,4 @@ class Samsung36Protocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Samsung36) -template class Samsung36Action : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint16_t, address) - TEMPLATABLE_VALUE(uint32_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - Samsung36Data data{}; - data.address = this->address_.value(x...); - data.command = this->command_.value(x...); - Samsung36Protocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/samsung_protocol.cpp b/esphome/components/remote_base/samsung_protocol.cpp index 7190e97403..7fce46313b 100644 --- a/esphome/components/remote_base/samsung_protocol.cpp +++ b/esphome/components/remote_base/samsung_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.samsung"; +ESPHOME_LOG_TAG(TAG, "remote.samsung"); static constexpr uint32_t HEADER_HIGH_US = 4500; static constexpr uint32_t HEADER_LOW_US = 4500; diff --git a/esphome/components/remote_base/samsung_protocol.h b/esphome/components/remote_base/samsung_protocol.h index dfa22ff85c..47aaa08e3a 100644 --- a/esphome/components/remote_base/samsung_protocol.h +++ b/esphome/components/remote_base/samsung_protocol.h @@ -21,17 +21,4 @@ class SamsungProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Samsung) -template class SamsungAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SamsungData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - SamsungProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/somfy_rts_protocol.cpp b/esphome/components/remote_base/somfy_rts_protocol.cpp new file mode 100644 index 0000000000..21c1f1d457 --- /dev/null +++ b/esphome/components/remote_base/somfy_rts_protocol.cpp @@ -0,0 +1,69 @@ +#include "somfy_rts_protocol.h" +#include "esphome/core/log.h" + +namespace esphome::remote_base { + +// Timings from https://pushstack.wordpress.com/somfy-rts-protocol/ +static constexpr uint8_t FRAME_SIZE_IN_BYTES = 7; +static constexpr uint16_t HALF_BIT_US = 604; + +static constexpr uint16_t WAKEUP_HIGH_US = 9415; +static constexpr uint32_t WAKEUP_LOW_US = 89565; + +static constexpr uint16_t HW_SYNC_US = 2560; + +static constexpr uint16_t SW_SYNC_HIGH_US = 4550; +static constexpr uint16_t SW_SYNC_LOW_US = HALF_BIT_US; + +// Wakeup, two hardware syncs and a software sync (8), two entries per bit (112) +static constexpr uint32_t FRAME_ENTRIES = 8 + FRAME_SIZE_IN_BYTES * 8 * 2; + +void SomfyRtsProtocol::encode(RemoteTransmitData *dst, const SomfyRtsData &data) { + dst->reserve(FRAME_ENTRIES); + dst->set_carrier_frequency(0); + dst->item(WAKEUP_HIGH_US, WAKEUP_LOW_US); + dst->item(HW_SYNC_US, HW_SYNC_US); + dst->item(HW_SYNC_US, HW_SYNC_US); + dst->item(SW_SYNC_HIGH_US, SW_SYNC_LOW_US); + + uint8_t frame[FRAME_SIZE_IN_BYTES]; + // Some non standard remotes put the control nibble in the key byte and send 0xF in the control nibble + if (data.control_in_key) { + frame[0] = (data.key & 0xF0) | (data.control & 0x0F); + frame[1] = 0xF0; + } else { + frame[0] = data.key; + frame[1] = (data.control & 0x0F) << 4; + } + frame[2] = data.rolling_code >> 8; + frame[3] = data.rolling_code; + frame[4] = data.address >> 16; + frame[5] = data.address >> 8; + frame[6] = data.address; + + uint8_t crc = 0; + for (uint8_t data_byte : frame) { + crc ^= (data_byte >> 4) ^ (data_byte & 0xF); + } + frame[1] |= (crc & 0x0F); + + for (uint8_t i = 1; i < FRAME_SIZE_IN_BYTES; i++) { + frame[i] ^= frame[i - 1]; + } + + // Manchester, MSB first: a 1 is space then mark, a 0 is mark then space. Frames repeat with + // the 30 ms gap through the action's repeat option, not inside the frame. + for (uint8_t data_byte : frame) { + for (int8_t y = 7; y >= 0; y--) { + if (data_byte & (1 << y)) { + dst->space(HALF_BIT_US); + dst->mark(HALF_BIT_US); + } else { + dst->mark(HALF_BIT_US); + dst->space(HALF_BIT_US); + } + } + } +} + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/somfy_rts_protocol.h b/esphome/components/remote_base/somfy_rts_protocol.h new file mode 100644 index 0000000000..df6ddd4831 --- /dev/null +++ b/esphome/components/remote_base/somfy_rts_protocol.h @@ -0,0 +1,21 @@ +#pragma once + +#include "remote_base.h" + +namespace esphome::remote_base { + +struct SomfyRtsData { + uint32_t address; + uint16_t rolling_code; + uint8_t key; + uint8_t control; + bool control_in_key; +}; + +// Transmit only: there is no decoder yet, so no binary sensor, trigger or dumper is registered. +class SomfyRtsProtocol : public RemoteProtocol { + public: + void encode(RemoteTransmitData *dst, const SomfyRtsData &data); +}; + +} // namespace esphome::remote_base diff --git a/esphome/components/remote_base/sony_protocol.cpp b/esphome/components/remote_base/sony_protocol.cpp index 0abb7fc0e0..00d5aee9c8 100644 --- a/esphome/components/remote_base/sony_protocol.cpp +++ b/esphome/components/remote_base/sony_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.sony"; +ESPHOME_LOG_TAG(TAG, "remote.sony"); static constexpr uint32_t HEADER_HIGH_US = 2400; static constexpr uint32_t HEADER_LOW_US = 600; diff --git a/esphome/components/remote_base/sony_protocol.h b/esphome/components/remote_base/sony_protocol.h index f83b2908b6..9bf488099b 100644 --- a/esphome/components/remote_base/sony_protocol.h +++ b/esphome/components/remote_base/sony_protocol.h @@ -23,17 +23,4 @@ class SonyProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Sony) -template class SonyAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SonyData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - SonyProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/symphony_protocol.cpp b/esphome/components/remote_base/symphony_protocol.cpp index 8b39abf9ad..962cf12ff6 100644 --- a/esphome/components/remote_base/symphony_protocol.cpp +++ b/esphome/components/remote_base/symphony_protocol.cpp @@ -5,7 +5,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.symphony"; +ESPHOME_LOG_TAG(TAG, "remote.symphony"); // Reference implementation and timing details: // IRremoteESP8266 ir_Symphony.cpp diff --git a/esphome/components/remote_base/symphony_protocol.h b/esphome/components/remote_base/symphony_protocol.h index 40a5c2daec..56a4d7c6d3 100644 --- a/esphome/components/remote_base/symphony_protocol.h +++ b/esphome/components/remote_base/symphony_protocol.h @@ -24,19 +24,4 @@ class SymphonyProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Symphony) -template class SymphonyAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint32_t, data) - TEMPLATABLE_VALUE(uint8_t, nbits) - TEMPLATABLE_VALUE(uint8_t, repeats) - - void encode(RemoteTransmitData *dst, Ts... x) override { - SymphonyData data{}; - data.data = this->data_.value(x...); - data.nbits = this->nbits_.value(x...); - data.repeats = this->repeats_.value(x...); - SymphonyProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toshiba_ac_protocol.cpp b/esphome/components/remote_base/toshiba_ac_protocol.cpp index 077b4340fa..addcd6f9cb 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.cpp +++ b/esphome/components/remote_base/toshiba_ac_protocol.cpp @@ -4,7 +4,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.toshibaac"; +ESPHOME_LOG_TAG(TAG, "remote.toshibaac"); static constexpr uint32_t HEADER_HIGH_US = 4500; static constexpr uint32_t HEADER_LOW_US = 4500; diff --git a/esphome/components/remote_base/toshiba_ac_protocol.h b/esphome/components/remote_base/toshiba_ac_protocol.h index 35d5af314c..482b338cda 100644 --- a/esphome/components/remote_base/toshiba_ac_protocol.h +++ b/esphome/components/remote_base/toshiba_ac_protocol.h @@ -21,17 +21,4 @@ class ToshibaAcProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(ToshibaAc) -template class ToshibaAcAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint64_t, rc_code_1) - TEMPLATABLE_VALUE(uint64_t, rc_code_2) - - void encode(RemoteTransmitData *dst, Ts... x) override { - ToshibaAcData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - ToshibaAcProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_base/toto_protocol.cpp b/esphome/components/remote_base/toto_protocol.cpp index 042efcbc36..c94fce87be 100644 --- a/esphome/components/remote_base/toto_protocol.cpp +++ b/esphome/components/remote_base/toto_protocol.cpp @@ -3,7 +3,7 @@ namespace esphome::remote_base { -static const char *const TAG = "remote.toto"; +ESPHOME_LOG_TAG(TAG, "remote.toto"); static constexpr uint32_t PREAMBLE_HIGH_US = 6200; static constexpr uint32_t PREAMBLE_LOW_US = 2800; diff --git a/esphome/components/remote_base/toto_protocol.h b/esphome/components/remote_base/toto_protocol.h index 8e965a5c73..c845087684 100644 --- a/esphome/components/remote_base/toto_protocol.h +++ b/esphome/components/remote_base/toto_protocol.h @@ -23,19 +23,4 @@ class TotoProtocol : public RemoteProtocol { DECLARE_REMOTE_PROTOCOL(Toto) -template class TotoAction : public RemoteTransmitterActionBase { - public: - TEMPLATABLE_VALUE(uint8_t, rc_code_1) - TEMPLATABLE_VALUE(uint8_t, rc_code_2) - TEMPLATABLE_VALUE(uint8_t, command) - - void encode(RemoteTransmitData *dst, Ts... x) override { - TotoData data{}; - data.rc_code_1 = this->rc_code_1_.value(x...); - data.rc_code_2 = this->rc_code_2_.value(x...); - data.command = this->command_.value(x...); - TotoProtocol().encode(dst, data); - } -}; - } // namespace esphome::remote_base diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index 866e108131..481acaa06c 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -29,6 +29,7 @@ CONF_FILTER_SYMBOLS = "filter_symbols" CONF_RECEIVE_SYMBOLS = "receive_symbols" AUTO_LOAD = ["remote_base"] +DOMAIN = "remote_receiver" remote_receiver_ns = cg.esphome_ns.namespace("remote_receiver") remote_base_ns = cg.esphome_ns.namespace("remote_base") diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index b3e4649096..58c72a8bb8 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -7,7 +7,7 @@ namespace esphome::remote_receiver { -static const char *const TAG = "remote_receiver"; +ESPHOME_LOG_TAG(TAG, "remote_receiver"); static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_t delta, bool level) { // convert level to -1 or +1 and write the delta to the buffer diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 6f93979b18..1c1c473f47 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -88,8 +88,8 @@ class RemoteReceiverComponent final : public remote_base::RemoteReceiverBase, rmt_channel_handle_t channel_{NULL}; uint32_t filter_symbols_{0}; uint32_t receive_symbols_{0}; - bool with_dma_{false}; uint32_t carrier_frequency_{0}; + bool with_dma_{false}; uint8_t carrier_duty_percent_{100}; #endif diff --git a/esphome/components/remote_receiver/remote_receiver_rmt.cpp b/esphome/components/remote_receiver/remote_receiver_rmt.cpp index 64392aa7ee..2b76ab8989 100644 --- a/esphome/components/remote_receiver/remote_receiver_rmt.cpp +++ b/esphome/components/remote_receiver/remote_receiver_rmt.cpp @@ -10,7 +10,7 @@ namespace esphome::remote_receiver { -static const char *const TAG = "remote_receiver"; +ESPHOME_LOG_TAG(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) { diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 58392c48ab..7553ed767b 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -20,13 +20,13 @@ 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__) AUTO_LOAD = ["remote_base"] +DOMAIN = "remote_transmitter" CONF_EOT_LEVEL = "eot_level" CONF_NON_BLOCKING = "non_blocking" @@ -38,11 +38,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 +133,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..34eb67b3c5 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -7,7 +7,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); void RemoteTransmitterComponent::setup() { this->pin_->setup(); @@ -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 99e1ce9504..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) diff --git a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp index 822389ccf9..dced3720b4 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp @@ -19,7 +19,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); #ifdef REMOTE_TRANSMITTER_BK_PWM diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp index fad91f593f..55f18277ce 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -12,7 +12,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); // Margin past a transmission's expected duration before the chain is declared stalled static constexpr uint32_t STALL_MARGIN_MS = 1000; @@ -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 6d27be8d47..22a63ca4fd 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rmt.cpp @@ -9,7 +9,9 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); + +static constexpr uint32_t COMPLETE_TIMEOUT_ID = 0; // Maximum RMT symbol duration (15-bit field) static constexpr uint32_t RMT_SYMBOL_DURATION_MAX = 0x7FFF; @@ -200,25 +202,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_TIMEOUT_ID)) { + this->wait_for_rmt_(); + } +} + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { uint64_t total_duration = 0; if (this->is_failed()) { + // both triggers still fire, so a paced API client or on_complete automation is not left waiting + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } - // if the timeout was cancelled, block until the tx is complete - if (this->non_blocking_ && this->cancel_timeout("complete")) { - this->wait_for_rmt_(); - } - if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); this->configure_rmt_(); + if (this->is_failed()) { // the carrier change failed, there is no channel to send on + this->transmit_trigger_.trigger(); + this->fire_complete_(false); + return; + } } this->rmt_temp_.clear(); @@ -258,6 +270,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.size() <= offset) { ESP_LOGE(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } @@ -273,24 +287,36 @@ 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_(); }); + this->set_timeout(COMPLETE_TIMEOUT_ID, total_duration / 1000, [this]() { this->wait_for_rmt_(); }); } else { this->wait_for_rmt_(); } } #else +void RemoteTransmitterComponent::flush_pending_completion() {} + void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - if (this->is_failed()) + if (this->is_failed()) { + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; + } if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) { this->current_carrier_frequency_ = this->temp_.get_carrier_frequency(); this->configure_rmt_(); + if (this->is_failed()) { // the carrier change failed, there is no channel to send on + this->transmit_trigger_.trigger(); + this->fire_complete_(false); + return; + } } this->rmt_temp_.clear(); @@ -328,9 +354,12 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen if ((this->rmt_temp_.data() == nullptr) || this->rmt_temp_.empty()) { ESP_LOGE(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->fire_complete_(false); return; } this->transmit_trigger_.trigger(); + bool sent = send_times != 0; // same answer as the ISR backend for a frame sent zero times for (uint32_t i = 0; i < send_times; i++) { rmt_transmit_config_t config; memset(&config, 0, sizeof(config)); @@ -340,18 +369,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..62be203ee3 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -15,7 +15,7 @@ namespace esphome::remote_transmitter { -static const char *const TAG = "remote_transmitter"; +ESPHOME_LOG_TAG(TAG, "remote_transmitter"); // PWM peripheral carrier, envelope paced by a gtimer interrupt chain. Bit-banging would need // interrupts disabled for the whole frame, but this core's micros() derives from the FreeRTOS @@ -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/renogy_inverter_ble/__init__.py b/esphome/components/renogy_inverter_ble/__init__.py new file mode 100644 index 0000000000..808f27a67a --- /dev/null +++ b/esphome/components/renogy_inverter_ble/__init__.py @@ -0,0 +1,40 @@ +import esphome.codegen as cg +from esphome.components import ble_client +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +AUTO_LOAD = ["sensor"] +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["ble_client"] +DOMAIN = "renogy_inverter_ble" +MULTI_CONF = True + +CONF_RENOGY_INVERTER_BLE_ID = "renogy_inverter_ble_id" + +renogy_inverter_ble_ns = cg.esphome_ns.namespace(DOMAIN) +RenogyInverterBle = renogy_inverter_ble_ns.class_( + "RenogyInverterBle", ble_client.BLEClientNode, cg.PollingComponent +) + +RENOGY_INVERTER_BLE_COMPONENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_RENOGY_INVERTER_BLE_ID): cv.use_id(RenogyInverterBle), + } +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(RenogyInverterBle), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await ble_client.register_ble_node(var, config) diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp new file mode 100644 index 0000000000..4cb43b5efb --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.cpp @@ -0,0 +1,248 @@ +#include "renogy_inverter_ble.h" + +#ifdef USE_ESP32 + +#include +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::renogy_inverter_ble { + +ESPHOME_LOG_TAG(TAG, "renogy_inverter_ble"); + +static constexpr uint16_t SERVICE_NOTIFY_UUID = 0xFFF0; +static constexpr uint16_t CHAR_NOTIFY_UUID = 0xFFF1; +static constexpr uint16_t SERVICE_WRITE_UUID = 0xFFD0; +static constexpr uint16_t CHAR_WRITE_UUID = 0xFFD1; +static constexpr uint16_t CHAR_INIT_UUID = 0xFFD4; + +static constexpr uint8_t MODBUS_DEVICE_ID = 0x20; +static constexpr uint8_t MODBUS_READ_HOLDING = 0x03; +static constexpr uint16_t REG_MAIN = 4000; +static constexpr uint16_t REG_MAIN_WORDS = 32; +static constexpr uint16_t REG_LOAD = 4408; +static constexpr uint16_t REG_LOAD_WORDS = 6; + +// Watchdog: returns the cycle to IDLE when the inverter stops answering +static constexpr uint32_t CYCLE_TIMEOUT_MS = 3000; +// Gap the inverter needs between two consecutive register reads +static constexpr uint32_t LOAD_READ_DELAY_MS = 300; + +static constexpr uint32_t CYCLE_TIMEOUT_ID = 0; +static constexpr uint32_t LOAD_READ_ID = 1; + +static void publish(sensor::Sensor *s, float value) { + if (s != nullptr) { + s->publish_state(value); + } +} + +void RenogyInverterBle::dump_config() { + ESP_LOGCONFIG(TAG, "Renogy Inverter BLE:"); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "AC input voltage", this->ac_input_voltage_sensor_); + LOG_SENSOR(" ", "AC output voltage", this->ac_output_voltage_sensor_); + LOG_SENSOR(" ", "AC output current", this->ac_output_current_sensor_); + LOG_SENSOR(" ", "AC output frequency", this->ac_output_frequency_sensor_); + LOG_SENSOR(" ", "Input frequency", this->input_frequency_sensor_); + LOG_SENSOR(" ", "Battery voltage", this->battery_voltage_sensor_); + LOG_SENSOR(" ", "Temperature", this->temperature_sensor_); + LOG_SENSOR(" ", "Load current", this->load_current_sensor_); + LOG_SENSOR(" ", "Load active power", this->load_active_power_sensor_); + LOG_SENSOR(" ", "Load apparent power", this->load_apparent_power_sensor_); +} + +void RenogyInverterBle::abort_cycle_() { + this->cancel_timeout(CYCLE_TIMEOUT_ID); + this->cancel_timeout(LOAD_READ_ID); + this->frame_len_ = 0; + this->state_ = State::IDLE; +} + +void RenogyInverterBle::read_register_(uint16_t start_register, uint16_t word_count) { + std::array req{MODBUS_DEVICE_ID, + MODBUS_READ_HOLDING, + static_cast(start_register >> 8), + static_cast(start_register), + static_cast(word_count >> 8), + static_cast(word_count)}; + const uint16_t crc = crc16(req.data(), 6); + req[6] = crc & 0xFF; // Modbus sends the CRC low byte first + req[7] = crc >> 8; + + this->frame_len_ = 0; + const esp_err_t status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->write_handle_, + req.size(), req.data(), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "write_char (reg %u) failed, status=%d", start_register, status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::start_cycle_() { + this->set_timeout(CYCLE_TIMEOUT_ID, CYCLE_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "Read cycle timed out (state=%u); resetting", static_cast(this->state_)); + this->abort_cycle_(); + }); + if (this->init_handle_ == 0) { + // No init characteristic discovered; try the main read directly + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + return; + } + this->state_ = State::INIT; + const esp_err_t status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), + this->init_handle_, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_OK) { + ESP_LOGW(TAG, "init read_char failed, status=%d", status); + this->abort_cycle_(); + } +} + +void RenogyInverterBle::update() { + if (this->node_state != espbt::ClientState::ESTABLISHED) { + ESP_LOGD(TAG, "Not connected yet; skipping poll"); + return; + } + if (this->state_ != State::IDLE) { + ESP_LOGD(TAG, "Previous read cycle still in progress; skipping"); + return; + } + this->start_cycle_(); +} + +void RenogyInverterBle::on_frame_complete_(uint16_t len) { + // A Modbus frame with its CRC appended checks to zero + if (len < 5 || crc16(this->frame_.data(), len) != 0) { + ESP_LOGW(TAG, "CRC mismatch (state=%u, len=%u)", static_cast(this->state_), len); + this->abort_cycle_(); + return; + } + // Exception response: function code with the high bit set, third byte is the exception code + if ((this->frame_[1] & 0x80) != 0) { + ESP_LOGW(TAG, "Modbus exception 0x%02X (state=%u)", this->frame_[2], static_cast(this->state_)); + this->abort_cycle_(); + return; + } + // Only the reply to the outstanding read is accepted: same device, same function, the requested word count + const bool awaiting_reply = this->state_ == State::MAIN || this->state_ == State::LOAD; + const uint16_t expected_words = this->state_ == State::MAIN ? REG_MAIN_WORDS : REG_LOAD_WORDS; + if (!awaiting_reply || this->frame_[0] != MODBUS_DEVICE_ID || this->frame_[1] != MODBUS_READ_HOLDING || + this->frame_[2] != 2 * expected_words) { + ESP_LOGW(TAG, "Unexpected response %02X %02X %02X (state=%u)", this->frame_[0], this->frame_[1], this->frame_[2], + static_cast(this->state_)); + this->abort_cycle_(); + return; + } + auto reg16 = [this](uint8_t i) { return encode_uint16(this->frame_[3 + 2 * i], this->frame_[4 + 2 * i]); }; + + if (this->state_ == State::MAIN) { + publish(this->ac_input_voltage_sensor_, reg16(0) * 0.1f); + publish(this->ac_output_voltage_sensor_, reg16(2) * 0.1f); + publish(this->ac_output_current_sensor_, reg16(3) * 0.01f); + publish(this->ac_output_frequency_sensor_, reg16(4) * 0.01f); + publish(this->battery_voltage_sensor_, reg16(5) * 0.1f); + publish(this->temperature_sensor_, reg16(6) * 0.1f); + publish(this->input_frequency_sensor_, reg16(9) * 0.01f); + this->frame_len_ = 0; + this->set_timeout(LOAD_READ_ID, LOAD_READ_DELAY_MS, [this]() { + this->state_ = State::LOAD; + this->read_register_(REG_LOAD, REG_LOAD_WORDS); + }); + return; + } + publish(this->load_current_sensor_, reg16(0) * 0.01f); + publish(this->load_active_power_sensor_, static_cast(reg16(1))); + publish(this->load_apparent_power_sensor_, static_cast(reg16(2))); + this->abort_cycle_(); +} + +void RenogyInverterBle::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + this->abort_cycle_(); + this->node_state = espbt::ClientState::IDLE; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + auto *notify_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_NOTIFY_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_NOTIFY_UUID)); + auto *write_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_WRITE_UUID)); + auto *init_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID), + espbt::ESPBTUUID::from_uint16(CHAR_INIT_UUID)); + if (notify_chr == nullptr || write_chr == nullptr) { + ESP_LOGE(TAG, "Required characteristics not found; not a Renogy inverter?"); + break; + } + this->notify_handle_ = notify_chr->handle; + this->write_handle_ = write_chr->handle; + this->init_handle_ = (init_chr != nullptr) ? init_chr->handle : 0; + ESP_LOGI(TAG, "handles: notify=%u write=%u init=%u", this->notify_handle_, this->write_handle_, + this->init_handle_); + const esp_err_t status = this->parent_->register_for_notify(this->notify_handle_); + if (status != ESP_OK) { + ESP_LOGW(TAG, "register_for_notify failed, status=%d", status); + } + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + // Other nodes on the same client see this event too + if (param->reg_for_notify.handle != this->notify_handle_) { + break; + } + if (param->reg_for_notify.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "Notification registration failed, status=%d", param->reg_for_notify.status); + break; + } + this->node_state = espbt::ClientState::ESTABLISHED; + ESP_LOGI(TAG, "Connected; will poll the inverter every update interval"); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (this->state_ != State::INIT || param->read.handle != this->init_handle_) { + break; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "init read failed, status=%d", param->read.status); + this->abort_cycle_(); + break; + } + // Init handshake done; the inverter now answers Modbus reads + this->state_ = State::MAIN; + this->read_register_(REG_MAIN, REG_MAIN_WORDS); + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.handle != this->notify_handle_) { + break; + } + const uint16_t value_len = param->notify.value_len; + if (this->frame_len_ + value_len > MAX_FRAME) { + ESP_LOGW(TAG, "Frame overflow; resetting"); + this->abort_cycle_(); + break; + } + memcpy(&this->frame_[this->frame_len_], param->notify.value, value_len); + this->frame_len_ += value_len; + if (this->frame_len_ < 3) { + break; + } + // An exception frame ([id][func|0x80][exc][crc][crc]) is 5 bytes; a normal response is + // [id][func][byte_count][...data][crc][crc] + const uint16_t expected_len = ((this->frame_[1] & 0x80) != 0) ? 5 : 3 + this->frame_[2] + 2; + if (this->frame_len_ >= expected_len) { + this->on_frame_complete_(expected_len); + } + break; + } + default: + break; + } +} + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h new file mode 100644 index 0000000000..41bcdddf4b --- /dev/null +++ b/esphome/components/renogy_inverter_ble/renogy_inverter_ble.h @@ -0,0 +1,59 @@ +#pragma once + +#ifdef USE_ESP32 + +#include +#include +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/component.h" + +namespace esphome::renogy_inverter_ble { + +namespace espbt = esphome::esp32_ble_tracker; + +// Largest Modbus response: 3 header + 2 * 32 data + 2 CRC bytes +static constexpr size_t MAX_FRAME = 69; + +// Renogy inverter BLE GATT (RIV1230PU). Notify char 0xFFF1 (svc 0xFFF0), write char 0xFFD1 +// (svc 0xFFD0), init char 0xFFD4 (svc 0xFFD0). Modbus device id 0x20, function 0x03. +class RenogyInverterBle : public ble_client::BLEClientNode, public PollingComponent { + SUB_SENSOR(ac_input_voltage) + SUB_SENSOR(ac_output_voltage) + SUB_SENSOR(ac_output_current) + SUB_SENSOR(ac_output_frequency) + SUB_SENSOR(input_frequency) + SUB_SENSOR(battery_voltage) + SUB_SENSOR(temperature) + SUB_SENSOR(load_current) + SUB_SENSOR(load_active_power) + SUB_SENSOR(load_apparent_power) + + public: + void dump_config() override; + void update() override; + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + + protected: + // Read cycle: IDLE -> (update) INIT read(0xFFD4) -> MAIN read(4000) -> LOAD read(4408) -> IDLE + enum class State : uint8_t { IDLE, INIT, MAIN, LOAD }; + + void start_cycle_(); + void read_register_(uint16_t start_register, uint16_t word_count); + void on_frame_complete_(uint16_t len); + void abort_cycle_(); + + State state_{State::IDLE}; + uint8_t frame_len_{0}; + uint16_t notify_handle_{0}; + uint16_t write_handle_{0}; + uint16_t init_handle_{0}; + // Reassembles the Modbus response, which arrives in MTU-sized notification chunks + std::array frame_; +}; + +} // namespace esphome::renogy_inverter_ble + +#endif // USE_ESP32 diff --git a/esphome/components/renogy_inverter_ble/sensor.py b/esphome/components/renogy_inverter_ble/sensor.py new file mode 100644 index 0000000000..9b87cc6503 --- /dev/null +++ b/esphome/components/renogy_inverter_ble/sensor.py @@ -0,0 +1,99 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_VOLTAGE, + CONF_TEMPERATURE, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_FREQUENCY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_CELSIUS, + UNIT_HERTZ, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, +) +from esphome.types import ConfigType + +from . import CONF_RENOGY_INVERTER_BLE_ID, RENOGY_INVERTER_BLE_COMPONENT_SCHEMA + +CODEOWNERS = ["@emilioaray-dev"] +DEPENDENCIES = ["renogy_inverter_ble"] + +CONF_AC_INPUT_VOLTAGE = "ac_input_voltage" +CONF_AC_OUTPUT_VOLTAGE = "ac_output_voltage" +CONF_AC_OUTPUT_CURRENT = "ac_output_current" +CONF_AC_OUTPUT_FREQUENCY = "ac_output_frequency" +CONF_INPUT_FREQUENCY = "input_frequency" +CONF_LOAD_CURRENT = "load_current" +CONF_LOAD_ACTIVE_POWER = "load_active_power" +CONF_LOAD_APPARENT_POWER = "load_apparent_power" + +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, +) +FREQUENCY_SCHEMA = sensor.sensor_schema( + unit_of_measurement=UNIT_HERTZ, + accuracy_decimals=2, + device_class=DEVICE_CLASS_FREQUENCY, + state_class=STATE_CLASS_MEASUREMENT, +) + +CONFIG_SCHEMA = RENOGY_INVERTER_BLE_COMPONENT_SCHEMA.extend( + { + cv.Optional(CONF_AC_INPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_AC_OUTPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_INPUT_FREQUENCY): FREQUENCY_SCHEMA, + cv.Optional(CONF_BATTERY_VOLTAGE): VOLTAGE_SCHEMA, + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LOAD_CURRENT): CURRENT_SCHEMA, + cv.Optional(CONF_LOAD_ACTIVE_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_LOAD_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=0, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_RENOGY_INVERTER_BLE_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_AC_INPUT_VOLTAGE, hub.set_ac_input_voltage_sensor) + await sensors(CONF_AC_OUTPUT_VOLTAGE, hub.set_ac_output_voltage_sensor) + await sensors(CONF_AC_OUTPUT_CURRENT, hub.set_ac_output_current_sensor) + await sensors(CONF_AC_OUTPUT_FREQUENCY, hub.set_ac_output_frequency_sensor) + await sensors(CONF_INPUT_FREQUENCY, hub.set_input_frequency_sensor) + await sensors(CONF_BATTERY_VOLTAGE, hub.set_battery_voltage_sensor) + await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor) + await sensors(CONF_LOAD_CURRENT, hub.set_load_current_sensor) + await sensors(CONF_LOAD_ACTIVE_POWER, hub.set_load_active_power_sensor) + await sensors(CONF_LOAD_APPARENT_POWER, hub.set_load_apparent_power_sensor) diff --git a/esphome/components/resampler/__init__.py b/esphome/components/resampler/__init__.py index e69de29bb2..e41b004b86 100644 --- a/esphome/components/resampler/__init__.py +++ b/esphome/components/resampler/__init__.py @@ -0,0 +1,17 @@ +from typing import Any + +import esphome.codegen as cg +import esphome.config_validation as cv + +DOMAIN = "resampler" + +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..a50f6adea8 --- /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 { + +ESPHOME_LOG_TAG(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/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index 16d2d5dc9e..99b00ec094 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -22,7 +22,7 @@ static const uint32_t TASK_STACK_SIZE = 3072; static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; -static const char *const TAG = "resampler_speaker"; +ESPHOME_LOG_TAG(TAG, "resampler_speaker"); enum ResamplingEventGroupBits : uint32_t { COMMAND_STOP = (1 << 0), // signals stop request diff --git a/esphome/components/resistance/__init__.py b/esphome/components/resistance/__init__.py index e69de29bb2..5bba63cc1b 100644 --- a/esphome/components/resistance/__init__.py +++ b/esphome/components/resistance/__init__.py @@ -0,0 +1 @@ +DOMAIN = "resistance" diff --git a/esphome/components/resistance/resistance_sensor.cpp b/esphome/components/resistance/resistance_sensor.cpp index 7522d026a4..d6bc324fa4 100644 --- a/esphome/components/resistance/resistance_sensor.cpp +++ b/esphome/components/resistance/resistance_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::resistance { -static const char *const TAG = "resistance"; +ESPHOME_LOG_TAG(TAG, "resistance"); void ResistanceSensor::dump_config() { LOG_SENSOR("", "Resistance Sensor", this); diff --git a/esphome/components/restart/__init__.py b/esphome/components/restart/__init__.py index f70ffa9520..6337520ebd 100644 --- a/esphome/components/restart/__init__.py +++ b/esphome/components/restart/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "restart" diff --git a/esphome/components/restart/button/restart_button.cpp b/esphome/components/restart/button/restart_button.cpp index d6404315ea..cffa47896c 100644 --- a/esphome/components/restart/button/restart_button.cpp +++ b/esphome/components/restart/button/restart_button.cpp @@ -5,7 +5,7 @@ namespace esphome::restart { -static const char *const TAG = "restart.button"; +ESPHOME_LOG_TAG(TAG, "restart.button"); void RestartButton::press_action() { ESP_LOGI(TAG, "Restarting device"); diff --git a/esphome/components/restart/switch/restart_switch.cpp b/esphome/components/restart/switch/restart_switch.cpp index 96a4fc40f5..50102366fc 100644 --- a/esphome/components/restart/switch/restart_switch.cpp +++ b/esphome/components/restart/switch/restart_switch.cpp @@ -5,7 +5,7 @@ namespace esphome::restart { -static const char *const TAG = "restart"; +ESPHOME_LOG_TAG(TAG, "restart"); void RestartSwitch::write_state(bool state) { // Acknowledge diff --git a/esphome/components/rf_bridge/__init__.py b/esphome/components/rf_bridge/__init__.py index fed8365e9e..e967335c18 100644 --- a/esphome/components/rf_bridge/__init__.py +++ b/esphome/components/rf_bridge/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( DEPENDENCIES = ["uart"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "rf_bridge" rf_bridge_ns = cg.esphome_ns.namespace("rf_bridge") RFBridgeComponent = rf_bridge_ns.class_( diff --git a/esphome/components/rf_bridge/rf_bridge.cpp b/esphome/components/rf_bridge/rf_bridge.cpp index a4a4da5d8c..86daaf4b08 100644 --- a/esphome/components/rf_bridge/rf_bridge.cpp +++ b/esphome/components/rf_bridge/rf_bridge.cpp @@ -7,7 +7,7 @@ namespace esphome::rf_bridge { -static const char *const TAG = "rf_bridge"; +ESPHOME_LOG_TAG(TAG, "rf_bridge"); void RFBridgeComponent::ack_() { ESP_LOGV(TAG, "Sending ACK"); diff --git a/esphome/components/rgb/__init__.py b/esphome/components/rgb/__init__.py index e69de29bb2..11e05410be 100644 --- a/esphome/components/rgb/__init__.py +++ b/esphome/components/rgb/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rgb" diff --git a/esphome/components/rgbct/__init__.py b/esphome/components/rgbct/__init__.py index e69de29bb2..b8a02914c5 100644 --- a/esphome/components/rgbct/__init__.py +++ b/esphome/components/rgbct/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rgbct" diff --git a/esphome/components/rgbw/__init__.py b/esphome/components/rgbw/__init__.py index e69de29bb2..0635bc36c3 100644 --- a/esphome/components/rgbw/__init__.py +++ b/esphome/components/rgbw/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rgbw" diff --git a/esphome/components/rgbww/__init__.py b/esphome/components/rgbww/__init__.py index e69de29bb2..ce95d11431 100644 --- a/esphome/components/rgbww/__init__.py +++ b/esphome/components/rgbww/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rgbww" diff --git a/esphome/components/ring_buffer/__init__.py b/esphome/components/ring_buffer/__init__.py index b53476dcac..d053a7e409 100644 --- a/esphome/components/ring_buffer/__init__.py +++ b/esphome/components/ring_buffer/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["esp32"] +DOMAIN = "ring_buffer" ring_buffer_ns = cg.esphome_ns.namespace("ring_buffer") RingBuffer = ring_buffer_ns.class_("RingBuffer") diff --git a/esphome/components/ring_buffer/ring_buffer.cpp b/esphome/components/ring_buffer/ring_buffer.cpp index 9604290cf0..7197a07348 100644 --- a/esphome/components/ring_buffer/ring_buffer.cpp +++ b/esphome/components/ring_buffer/ring_buffer.cpp @@ -7,11 +7,12 @@ namespace esphome::ring_buffer { -static const char *const TAG = "ring_buffer"; +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/rotary_encoder/__init__.py index e69de29bb2..5f448e531c 100644 --- a/esphome/components/rotary_encoder/__init__.py +++ b/esphome/components/rotary_encoder/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rotary_encoder" diff --git a/esphome/components/rotary_encoder/rotary_encoder.cpp b/esphome/components/rotary_encoder/rotary_encoder.cpp index 0734ca87d3..4c43830888 100644 --- a/esphome/components/rotary_encoder/rotary_encoder.cpp +++ b/esphome/components/rotary_encoder/rotary_encoder.cpp @@ -4,7 +4,7 @@ namespace esphome::rotary_encoder { -static const char *const TAG = "rotary_encoder"; +ESPHOME_LOG_TAG(TAG, "rotary_encoder"); // based on https://github.com/jkDesignDE/MechInputs/blob/master/QEIx4.cpp static const uint8_t STATE_LUT_MASK = 0x1C; // clears upper counter increment/decrement bits and pin states diff --git a/esphome/components/router/__init__.py b/esphome/components/router/__init__.py index e69de29bb2..49a5eec426 100644 --- a/esphome/components/router/__init__.py +++ b/esphome/components/router/__init__.py @@ -0,0 +1 @@ +DOMAIN = "router" 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.cpp b/esphome/components/router/speaker/router_speaker.cpp index dd2428e4df..5fdc1ac716 100644 --- a/esphome/components/router/speaker/router_speaker.cpp +++ b/esphome/components/router/speaker/router_speaker.cpp @@ -12,7 +12,7 @@ namespace esphome::router { -static const char *const TAG = "router.speaker"; +ESPHOME_LOG_TAG(TAG, "router.speaker"); // Maximum time to wait for the active output to report running after start() before giving up static const uint32_t STATE_TRANSITION_TIMEOUT_MS = 5000; 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 a1bbf6a3d6..d46858d397 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -57,6 +57,7 @@ from .gpio import rp2_pin_to_code # noqa: F401 _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@jesserockz"] AUTO_LOAD = ["preferences"] +DOMAIN = "rp2" IS_TARGET_PLATFORM = True # Legacy top-level YAML keys that route here. The framework @@ -194,23 +195,26 @@ def _parse_platform_version(value: Any) -> str: # * 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) +# * rp2_pio pins its own pioasm build (PIOASM_REPO_VERSION in rp2_pio/__init__.py); check +# https://github.com/earlephilhower/pico-quick-toolchain/releases for a matching release # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(6, 1, 1) # The raspberrypi platform version to use for arduino frameworks # - https://github.com/maxgerhardt/platform-raspberrypi/tags -# develop-branch commit carrying the arduino-pico 6.1.0 update and the board -# JSON files it adds; replace with a release tag when one is cut +# develop-branch commit carrying the arduino-pico 6.1.0 update; it predates +# 6.1.1, so the boards that release adds have no board JSON on it yet; replace +# with a release tag when one is cut RECOMMENDED_ARDUINO_PLATFORM_VERSION = "5d4561a05e3b212660ac6fdd3fbfb328d1988aa1" def _arduino_check_versions(value: ConfigType) -> ConfigType: value = value.copy() lookups = { - "dev": (cv.Version(6, 1, 0), "https://github.com/earlephilhower/arduino-pico"), - "latest": (cv.Version(6, 1, 0), None), + "dev": (cv.Version(6, 1, 1), "https://github.com/earlephilhower/arduino-pico"), + "latest": (cv.Version(6, 1, 1), None), "recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index a9ce11c33d..08536fd053 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1442,6 +1442,19 @@ RP2_BOARD_PINS = { "SS": 5, "TX": 0, }, + "waveshare_rp2350_core": { + "LED": 39, + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 9, + "SCL1": 11, + "SDA": 8, + "SDA1": 10, + "SS": 17, + "TX": 0, + }, "waveshare_rp2350_lcd_0_96": { "MISO": 16, "MOSI": 19, @@ -1479,6 +1492,18 @@ RP2_BOARD_PINS = { "SS": 17, "TX": 0, }, + "waveshare_rp2350_poe_eth": { + "MISO": 16, + "MOSI": 19, + "RX": 1, + "SCK": 18, + "SCL": 5, + "SCL1": 27, + "SDA": 4, + "SDA1": 26, + "SS": 17, + "TX": 0, + }, "waveshare_rp2350_zero": { "MISO": 4, "MOSI": 3, @@ -2286,6 +2311,12 @@ BOARDS = { "mcu": "rp2040", "max_pin": 29, }, + "waveshare_rp2350_core": { + "name": "Waveshare RP2350 Core", + "mcu": "rp2350", + "max_pin": 47, + "die": "B", + }, "waveshare_rp2350_lcd_0_96": { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", @@ -2304,6 +2335,12 @@ BOARDS = { "max_pin": 29, "die": "A", }, + "waveshare_rp2350_poe_eth": { + "name": "Waveshare RP2350-POE-ETH", + "mcu": "rp2350", + "max_pin": 29, + "die": "A", + }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", diff --git a/esphome/components/rp2/crash_handler.cpp b/esphome/components/rp2/crash_handler.cpp index 9bcdc8bee4..0741cc24e3 100644 --- a/esphome/components/rp2/crash_handler.cpp +++ b/esphome/components/rp2/crash_handler.cpp @@ -53,7 +53,7 @@ static constexpr size_t MAX_BACKTRACE = 4; namespace esphome::rp2 { -static const char *const TAG = "rp2.crash"; +ESPHOME_LOG_TAG(TAG, "rp2.crash"); // Filled from the watchdog scratch registers on the first read. static struct CrashData { diff --git a/esphome/components/rp2/gpio.cpp b/esphome/components/rp2/gpio.cpp index 0dbb124a26..2eeb460e95 100644 --- a/esphome/components/rp2/gpio.cpp +++ b/esphome/components/rp2/gpio.cpp @@ -1,14 +1,17 @@ #ifdef USE_RP2 #include "gpio.h" +#include "esphome/core/hal.h" #include "esphome/core/log.h" namespace esphome { namespace rp2 { -static const char *const TAG = "rp2"; +ESPHOME_LOG_TAG(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) { @@ -38,7 +41,7 @@ ISRInternalGPIOPin RP2GPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void RP2GPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void RP2GPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { PinStatus arduino_mode = LOW; switch (type) { case gpio::INTERRUPT_RISING_EDGE: diff --git a/esphome/components/rp2/gpio.h b/esphome/components/rp2/gpio.h index 538fef619a..b1d4c9a8f6 100644 --- a/esphome/components/rp2/gpio.h +++ b/esphome/components/rp2/gpio.h @@ -3,11 +3,11 @@ #ifdef USE_RP2 #include -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" namespace esphome::rp2 { -class RP2GPIOPin final : public InternalGPIOPin { +class RP2GPIOPin final : public GPIOPin { public: void set_pin(uint8_t pin) { pin_ = pin; } void set_inverted(bool inverted) { inverted_ = inverted; } @@ -18,14 +18,18 @@ class RP2GPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return pin_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return pin_; } gpio::Flags get_flags() const override { return flags_; } - bool is_inverted() const override { return inverted_; } + bool is_inverted() const { return inverted_; } + bool is_internal() override { return true; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; uint8_t pin_; bool inverted_{}; diff --git a/esphome/components/rp2/hal.h b/esphome/components/rp2/hal.h index ec46937bab..2a538f1571 100644 --- a/esphome/components/rp2/hal.h +++ b/esphome/components/rp2/hal.h @@ -4,6 +4,8 @@ #include +#include + #include "esphome/core/time_conversion.h" #define IRAM_ATTR __attribute__((noinline, long_call, section(".time_critical"))) @@ -40,6 +42,9 @@ __attribute__((always_inline)) inline bool in_isr_context() { return ipsr != 0; } +/// arduino-pico runs setup() and loop() on core 0; ESPHome never uses core 1. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return !in_isr_context() && get_core_num() == 0; } + __attribute__((always_inline)) inline void yield() { ::yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::delay(ms); } __attribute__((always_inline)) inline uint32_t micros() { return static_cast(::micros()); } diff --git a/esphome/components/rp2/preferences.cpp b/esphome/components/rp2/preferences.cpp index d1e0bc555f..dfe19f4bf4 100644 --- a/esphome/components/rp2/preferences.cpp +++ b/esphome/components/rp2/preferences.cpp @@ -14,7 +14,7 @@ namespace esphome::rp2 { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); static constexpr uint32_t RP2040_FLASH_STORAGE_SIZE = 512; diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index d2a08e9fc0..ad79551792 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -8,10 +8,10 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] CODEOWNERS = ["@bdraco"] +DOMAIN = "rp2040_ble" CONF_RP2040_BLE_ID = "rp2040_ble_id" -KEY_RP2040_BLE = "rp2040_ble" KEY_USED_CONNECTION_SLOTS = "used_connection_slots" # Hard platform cap on concurrent GATT connections: the BTstack pool overrides @@ -49,7 +49,7 @@ def consume_connection_slots( the total is checked against MAX_CONNECTIONS in final validation.""" def _consume_connection_slots(config: MutableMapping) -> MutableMapping: - data: dict = CORE.data.setdefault(KEY_RP2040_BLE, {}) + data: dict = CORE.data.setdefault(DOMAIN, {}) slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) slots.extend([consumer] * value) return config @@ -62,7 +62,7 @@ def validate_connection_slots() -> None: # Skip in testing mode to allow component grouping (esp32_ble parity). if CORE.testing_mode: return - used = CORE.data.get(KEY_RP2040_BLE, {}).get(KEY_USED_CONNECTION_SLOTS, []) + used = CORE.data.get(DOMAIN, {}).get(KEY_USED_CONNECTION_SLOTS, []) if len(used) > MAX_CONNECTIONS: raise cv.Invalid( f"BLE components require {len(used)} connection slots but the " diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp index 699555f623..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.1.0's prebuilt archives: a framework bump (or +// Pinned against arduino-pico 6.1.1's prebuilt archives: a framework bump (or // a changed ENABLE_* macro) shifting the struct layout must fail the build // here, not overrun the pool blocks at runtime. Sizes differ per core // architecture (measured from each archive's own storage symbols). GCC only: diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 80e8bf9415..fca0c45d6f 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -11,7 +11,7 @@ namespace esphome::rp2040_ble { -static const char *const TAG = "rp2040_ble"; +ESPHOME_LOG_TAG(TAG, "rp2040_ble"); // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) RP2040BLE *global_ble = nullptr; diff --git a/esphome/components/rp2040_pio_led_strip/__init__.py b/esphome/components/rp2040_pio_led_strip/__init__.py index 4c9aa2d155..10b24efa09 100644 --- a/esphome/components/rp2040_pio_led_strip/__init__.py +++ b/esphome/components/rp2040_pio_led_strip/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Papa-DMan"] +DOMAIN = "rp2040_pio_led_strip" diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp index 1f4bea9ecd..8ea9880c7c 100644 --- a/esphome/components/rp2040_pio_led_strip/led_strip.cpp +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -14,7 +14,7 @@ namespace esphome::rp2040_pio_led_strip { -static const char *const TAG = "rp2040_pio_led_strip"; +ESPHOME_LOG_TAG(TAG, "rp2040_pio_led_strip"); // DMA interrupt service routine void RP2040PIOLEDStripLightOutput::dma_write_complete_handler() { diff --git a/esphome/components/rp2040_pwm/__init__.py b/esphome/components/rp2040_pwm/__init__.py index e69de29bb2..3abe0b8c83 100644 --- a/esphome/components/rp2040_pwm/__init__.py +++ b/esphome/components/rp2040_pwm/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rp2040_pwm" diff --git a/esphome/components/rp2040_pwm/rp2040_pwm.cpp b/esphome/components/rp2040_pwm/rp2040_pwm.cpp index 270cc33551..8324da3dcf 100644 --- a/esphome/components/rp2040_pwm/rp2040_pwm.cpp +++ b/esphome/components/rp2040_pwm/rp2040_pwm.cpp @@ -13,7 +13,7 @@ namespace esphome::rp2040_pwm { -static const char *const TAG = "rp2040_pwm"; +ESPHOME_LOG_TAG(TAG, "rp2040_pwm"); void RP2040PWM::setup() { this->setup_pwm_(); } diff --git a/esphome/components/rp2_ble_tracker/__init__.py b/esphome/components/rp2_ble_tracker/__init__.py index 12857a556f..4a3d3f788b 100644 --- a/esphome/components/rp2_ble_tracker/__init__.py +++ b/esphome/components/rp2_ble_tracker/__init__.py @@ -33,6 +33,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["rp2"] AUTO_LOAD = ["ble_device_base", "rp2040_ble"] CODEOWNERS = ["@bdraco"] +DOMAIN = "rp2_ble_tracker" ble_device_base.register_hub_provider("rp2_ble_tracker") diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index a0a04b981d..192e19455e 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -9,7 +9,7 @@ namespace esphome::rp2_ble_tracker { -static const char *const TAG = "rp2_ble_tracker"; +ESPHOME_LOG_TAG(TAG, "rp2_ble_tracker"); // Floor between controller start attempts; insurance against a failing // scan_start() being retried every loop. diff --git a/esphome/components/rp2_pio/__init__.py b/esphome/components/rp2_pio/__init__.py index 915ab73b87..bfdff910da 100644 --- a/esphome/components/rp2_pio/__init__.py +++ b/esphome/components/rp2_pio/__init__.py @@ -5,11 +5,12 @@ import esphome.config_validation as cv from esphome.types import ConfigType DEPENDENCIES = ["rp2"] +DOMAIN = "rp2_pio" -PIOASM_REPO_VERSION = "1.5.0-b" +PIOASM_REPO_VERSION = "4.1.0" PIOASM_REPO_BASE = f"https://github.com/earlephilhower/pico-quick-toolchain/releases/download/{PIOASM_REPO_VERSION}" -PIOASM_VERSION = "pioasm-2e6142b.230216" +PIOASM_VERSION = "pioasm-9fdfe11.250530" PIOASM_DOWNLOADS = { "linux": { "aarch64": f"aarch64-linux-gnu.{PIOASM_VERSION}.tar.gz", @@ -20,8 +21,8 @@ PIOASM_DOWNLOADS = { "amd64": f"x86_64-w64-mingw32.{PIOASM_VERSION}.zip", }, "darwin": { - "x86_64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", - "arm64": f"x86_64-apple-darwin14.{PIOASM_VERSION}.tar.gz", + "x86_64": f"x86_64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", + "arm64": f"aarch64-apple-darwin20.4.{PIOASM_VERSION}.tar.gz", }, } diff --git a/esphome/components/rpi_dpi_rgb/__init__.py b/esphome/components/rpi_dpi_rgb/__init__.py index 8628f0e063..20629fd730 100644 --- a/esphome/components/rpi_dpi_rgb/__init__.py +++ b/esphome/components/rpi_dpi_rgb/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "rpi_dpi_rgb" DEPRECATED_COMPONENT = """ The 'rpi_dpi_rgb' component is deprecated and no new models will be added to it. diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 88928b2bce..d3d5150819 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -35,6 +35,7 @@ from .boards import RTL87XX_BOARD_PINS, RTL87XX_BOARDS CODEOWNERS = ["@kuba2k2"] AUTO_LOAD = ["libretiny"] +DOMAIN = "rtl87xx" IS_TARGET_PLATFORM = True COMPONENT_DATA = LibreTinyComponent( diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py index 4f4d17b441..47b8f26e3f 100644 --- a/esphome/components/rtttl/__init__.py +++ b/esphome/components/rtttl/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@glmnet", "@ximex"] +DOMAIN = "rtttl" CONF_RTTTL = "rtttl" CONF_ON_FINISHED_PLAYBACK = "on_finished_playback" diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp index a5f8567c9d..fb2bf63a64 100644 --- a/esphome/components/rtttl/rtttl.cpp +++ b/esphome/components/rtttl/rtttl.cpp @@ -6,7 +6,7 @@ namespace esphome::rtttl { -static const char *const TAG = "rtttl"; +ESPHOME_LOG_TAG(TAG, "rtttl"); static constexpr uint8_t SONG_NAME_LENGTH_LIMIT = 64; static constexpr uint8_t SEMITONES_IN_OCTAVE = 12; diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h index 7a32b79b17..055204571e 100644 --- a/esphome/components/rtttl/rtttl.h +++ b/esphome/components/rtttl/rtttl.h @@ -82,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. diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index e27fcadbee..fcf5cb770c 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -17,6 +17,7 @@ from esphome.core import CORE AUTO_LOAD = ["image"] CODEOWNERS = ["@guillempages", "@clydebarrow", "@kahrendt"] +DOMAIN = "runtime_image" CONF_PLACEHOLDER = "placeholder" CONF_TRANSPARENCY = "transparency" diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 204d6cc14b..9260860f40 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -10,7 +10,7 @@ namespace esphome::runtime_image { -static const char *const TAG = "image_decoder.bmp"; +ESPHOME_LOG_TAG(TAG, "image_decoder.bmp"); void BmpDecoder::reset() { ImageDecoder::reset(); diff --git a/esphome/components/runtime_image/image_decoder.cpp b/esphome/components/runtime_image/image_decoder.cpp index f2c4f5c8cd..7056ce35aa 100644 --- a/esphome/components/runtime_image/image_decoder.cpp +++ b/esphome/components/runtime_image/image_decoder.cpp @@ -6,7 +6,7 @@ namespace esphome::runtime_image { -static const char *const TAG = "image_decoder"; +ESPHOME_LOG_TAG(TAG, "image_decoder"); bool ImageDecoder::set_size(int width, int height) { bool success = this->image_->resize(width, height) > 0; 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/jpeg_decoder.cpp b/esphome/components/runtime_image/jpeg_decoder.cpp index 85ec945259..74ced63a4e 100644 --- a/esphome/components/runtime_image/jpeg_decoder.cpp +++ b/esphome/components/runtime_image/jpeg_decoder.cpp @@ -11,7 +11,7 @@ #include "esp_task_wdt.h" #endif -static const char *const TAG = "image_decoder.jpeg"; +ESPHOME_LOG_TAG(TAG, "image_decoder.jpeg"); namespace esphome::runtime_image { diff --git a/esphome/components/runtime_image/png_decoder.cpp b/esphome/components/runtime_image/png_decoder.cpp index 106f25bbe1..ae76347118 100644 --- a/esphome/components/runtime_image/png_decoder.cpp +++ b/esphome/components/runtime_image/png_decoder.cpp @@ -6,7 +6,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -static const char *const TAG = "image_decoder.png"; +ESPHOME_LOG_TAG(TAG, "image_decoder.png"); namespace esphome::runtime_image { diff --git a/esphome/components/runtime_image/qoi_decoder.cpp b/esphome/components/runtime_image/qoi_decoder.cpp index b0fabb5233..dacaace3fd 100644 --- a/esphome/components/runtime_image/qoi_decoder.cpp +++ b/esphome/components/runtime_image/qoi_decoder.cpp @@ -10,7 +10,7 @@ namespace esphome::runtime_image { -static const char *const TAG = "image_decoder.qoi"; +ESPHOME_LOG_TAG(TAG, "image_decoder.qoi"); constexpr uint8_t QOI_OP_RGB = 0b11111110; constexpr uint8_t QOI_OP_RGBA = 0b11111111; diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index ef92d0d707..6c10194574 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -21,7 +21,7 @@ namespace esphome::runtime_image { -static const char *const TAG = "runtime_image"; +ESPHOME_LOG_TAG(TAG, "runtime_image"); // Widest supported format is 4 bytes/pixel, so 32767 * 32767 * 4 still fits a 32-bit size_t static constexpr int MAX_IMAGE_DIMENSION = 32767; @@ -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/runtime_stats/__init__.py b/esphome/components/runtime_stats/__init__.py index 62f5f46cb4..2d1a6ac910 100644 --- a/esphome/components/runtime_stats/__init__.py +++ b/esphome/components/runtime_stats/__init__.py @@ -8,6 +8,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@bdraco"] +DOMAIN = "runtime_stats" CONF_LOG_INTERVAL = "log_interval" diff --git a/esphome/components/ruuvi_ble/__init__.py b/esphome/components/ruuvi_ble/__init__.py index ac4bafbb1a..ccfa8ecf10 100644 --- a/esphome/components/ruuvi_ble/__init__.py +++ b/esphome/components/ruuvi_ble/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base"] +DOMAIN = "ruuvi_ble" ruuvi_ble_ns = cg.esphome_ns.namespace("ruuvi_ble") RuuviListener = ruuvi_ble_ns.class_( diff --git a/esphome/components/ruuvi_ble/ruuvi_ble.cpp b/esphome/components/ruuvi_ble/ruuvi_ble.cpp index 19753b3c9e..c781e282fe 100644 --- a/esphome/components/ruuvi_ble/ruuvi_ble.cpp +++ b/esphome/components/ruuvi_ble/ruuvi_ble.cpp @@ -3,7 +3,7 @@ namespace esphome::ruuvi_ble { -static const char *const TAG = "ruuvi_ble"; +ESPHOME_LOG_TAG(TAG, "ruuvi_ble"); bool parse_ruuvi_data_byte(const ble_device_base::adv_data_t &adv_data, RuuviParseResult &result) { const uint8_t data_type = adv_data[0]; diff --git a/esphome/components/ruuvitag/__init__.py b/esphome/components/ruuvitag/__init__.py index e69de29bb2..2a0e78efc8 100644 --- a/esphome/components/ruuvitag/__init__.py +++ b/esphome/components/ruuvitag/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ruuvitag" diff --git a/esphome/components/ruuvitag/ruuvitag.cpp b/esphome/components/ruuvitag/ruuvitag.cpp index 1536befb1b..3dc4a3a223 100644 --- a/esphome/components/ruuvitag/ruuvitag.cpp +++ b/esphome/components/ruuvitag/ruuvitag.cpp @@ -3,7 +3,7 @@ namespace esphome::ruuvitag { -static const char *const TAG = "ruuvitag"; +ESPHOME_LOG_TAG(TAG, "ruuvitag"); void RuuviTag::dump_config() { ESP_LOGCONFIG(TAG, "RuuviTag"); 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 index e69de29bb2..e0b7464656 100644 --- a/esphome/components/rx8025t/__init__.py +++ b/esphome/components/rx8025t/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rx8025t" diff --git a/esphome/components/rx8025t/rx8025t.cpp b/esphome/components/rx8025t/rx8025t.cpp index fee2c83a5f..502f6f265d 100644 --- a/esphome/components/rx8025t/rx8025t.cpp +++ b/esphome/components/rx8025t/rx8025t.cpp @@ -11,7 +11,7 @@ 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"; +ESPHOME_LOG_TAG(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); } diff --git a/esphome/components/rx8130/__init__.py b/esphome/components/rx8130/__init__.py index e69de29bb2..3b5225c024 100644 --- a/esphome/components/rx8130/__init__.py +++ b/esphome/components/rx8130/__init__.py @@ -0,0 +1 @@ +DOMAIN = "rx8130" diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp index 1b5b71f0e0..63153c5db3 100644 --- a/esphome/components/rx8130/rx8130.cpp +++ b/esphome/components/rx8130/rx8130.cpp @@ -21,7 +21,7 @@ static const uint8_t RX8130_BIT_CTRL_STOP = 0x40; static const uint8_t RX8130_BAT_FLAGS = 0x30; static const uint8_t RX8130_CLEAR_FLAGS = 0x00; -static const char *const TAG = "rx8130"; +ESPHOME_LOG_TAG(TAG, "rx8130"); 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); } 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..8d77ff363f 100644 --- a/esphome/components/safe_mode/__init__.py +++ b/esphome/components/safe_mode/__init__.py @@ -6,15 +6,15 @@ from esphome.const import ( CONF_ID, CONF_NUM_ATTEMPTS, CONF_REBOOT_TIMEOUT, - CONF_SAFE_MODE, 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"] +DOMAIN = "safe_mode" CONF_BOOT_IS_GOOD_AFTER = "boot_is_good_after" CONF_BOOT_IS_GOOD_ON_SHUTDOWN = "boot_is_good_on_shutdown" @@ -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 = ( @@ -104,5 +92,5 @@ async def to_code(config: ConfigType) -> None: ) cg.add(RawExpression(f"if ({condition}) return")) - CORE.data[CONF_SAFE_MODE] = {} - CORE.data[CONF_SAFE_MODE][KEY_PAST_SAFE_MODE] = True + CORE.data[DOMAIN] = {} + CORE.data[DOMAIN][KEY_PAST_SAFE_MODE] = True 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/button/safe_mode_button.cpp b/esphome/components/safe_mode/button/safe_mode_button.cpp index 982ecf8402..e9779953f0 100644 --- a/esphome/components/safe_mode/button/safe_mode_button.cpp +++ b/esphome/components/safe_mode/button/safe_mode_button.cpp @@ -5,7 +5,7 @@ namespace esphome::safe_mode { -static const char *const TAG = "safe_mode.button"; +ESPHOME_LOG_TAG(TAG, "safe_mode.button"); void SafeModeButton::press_action() { ESP_LOGI(TAG, "Restarting in safe mode"); diff --git a/esphome/components/safe_mode/safe_mode.cpp b/esphome/components/safe_mode/safe_mode.cpp index 8fd5911ab5..3099df5860 100644 --- a/esphome/components/safe_mode/safe_mode.cpp +++ b/esphome/components/safe_mode/safe_mode.cpp @@ -21,7 +21,7 @@ namespace esphome::safe_mode { -static const char *const TAG = "safe_mode"; +ESPHOME_LOG_TAG(TAG, "safe_mode"); #if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS) // Find a non-running app partition. If verify is true, only returns a partition diff --git a/esphome/components/safe_mode/switch/safe_mode_switch.cpp b/esphome/components/safe_mode/switch/safe_mode_switch.cpp index b4b9735757..c60c59dbfc 100644 --- a/esphome/components/safe_mode/switch/safe_mode_switch.cpp +++ b/esphome/components/safe_mode/switch/safe_mode_switch.cpp @@ -5,7 +5,7 @@ namespace esphome::safe_mode { -static const char *const TAG = "safe_mode.switch"; +ESPHOME_LOG_TAG(TAG, "safe_mode.switch"); void SafeModeSwitch::write_state(bool state) { // Acknowledge diff --git a/esphome/components/samsung/__init__.py b/esphome/components/samsung/__init__.py new file mode 100644 index 0000000000..fb22727b36 --- /dev/null +++ b/esphome/components/samsung/__init__.py @@ -0,0 +1 @@ +DOMAIN = "samsung" diff --git a/esphome/components/samsung/climate.py b/esphome/components/samsung/climate.py new file mode 100644 index 0000000000..ae59d17f96 --- /dev/null +++ b/esphome/components/samsung/climate.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +from esphome.types import ConfigType + +CODEOWNERS = ["@jorofi"] + +AUTO_LOAD = ["climate_ir"] + +samsung_ns = cg.esphome_ns.namespace("samsung") +SamsungClimate = samsung_ns.class_("SamsungClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SamsungClimate) + + +async def to_code(config: ConfigType) -> None: + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/samsung/samsung.cpp b/esphome/components/samsung/samsung.cpp new file mode 100644 index 0000000000..65f1cd10d6 --- /dev/null +++ b/esphome/components/samsung/samsung.cpp @@ -0,0 +1,235 @@ +#include "samsung.h" + +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::samsung { + +ESPHOME_LOG_TAG(TAG, "samsung.climate"); + +static constexpr uint32_t CARRIER_FREQUENCY_HZ = 38000; +static constexpr uint32_t HEADER_MARK_US = 3000; +static constexpr uint32_t HEADER_SPACE_US = 9000; +static constexpr uint32_t BIT_MARK_US = 500; +static constexpr uint32_t ONE_SPACE_US = 1500; +static constexpr uint32_t ZERO_SPACE_US = 500; +static constexpr uint32_t SECTION_SPACE_US = 2000; + +static constexpr uint8_t EXTENDED_LENGTH = 3 * SAMSUNG_AC_SECTION_LENGTH; + +// A powered-on unit at 24 °C, cool mode, fan auto, swing off (from IRremoteESP8266); checksums are refilled on send +static const uint8_t RESET_STATE[SAMSUNG_AC_STATE_LENGTH] PROGMEM = {0x02, 0x92, 0x0F, 0x00, 0x00, 0x00, 0xF0, + 0x01, 0xE2, 0xFE, 0x71, 0x80, 0x11, 0xF0}; +// Middle section of a power on/off frame: an all-zero timer block +static const uint8_t TIMER_SECTION[SAMSUNG_AC_SECTION_LENGTH] PROGMEM = {0x01, 0xD2, 0x0F, 0x00, 0x00, 0x00, 0x00}; + +// Where each setting lives in the 14 byte state +struct Field { + uint8_t byte; + uint8_t shift; + uint8_t mask; +}; +static constexpr Field POWER_1{6, 4, 0b11}; +static constexpr Field SWING{9, 4, 0b111}; +static constexpr Field TEMP{11, 4, 0b1111}; +static constexpr Field FAN{12, 1, 0b111}; +static constexpr Field MODE{12, 4, 0b111}; +static constexpr Field POWER_2{13, 4, 0b11}; +static constexpr uint8_t POWER_ON = 0b11; + +static uint8_t get_field(const uint8_t *state, Field field) { return (state[field.byte] >> field.shift) & field.mask; } +static void set_field(uint8_t *state, Field field, uint8_t value) { + state[field.byte] = (state[field.byte] & ~(field.mask << field.shift)) | ((value & field.mask) << field.shift); +} +static bool is_powered_on(const uint8_t *state) { + return get_field(state, POWER_1) == POWER_ON && get_field(state, POWER_2) == POWER_ON; +} + +struct Code { + uint8_t value; // a climate enum, all of which are uint8_t + uint8_t code; +}; +// The first entry of each table is the fallback in both directions +static constexpr Code MODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, 0}, {climate::CLIMATE_MODE_COOL, 1}, {climate::CLIMATE_MODE_DRY, 2}, + {climate::CLIMATE_MODE_FAN_ONLY, 3}, {climate::CLIMATE_MODE_HEAT, 4}, +}; +static constexpr Code FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, 0}, + {climate::CLIMATE_FAN_LOW, 2}, + {climate::CLIMATE_FAN_MEDIUM, 4}, + {climate::CLIMATE_FAN_HIGH, 5}, +}; +static constexpr Code SWINGS[] PROGMEM = { + {climate::CLIMATE_SWING_OFF, 0b111}, + {climate::CLIMATE_SWING_VERTICAL, 0b010}, + {climate::CLIMATE_SWING_HORIZONTAL, 0b011}, + {climate::CLIMATE_SWING_BOTH, 0b100}, +}; + +// Returns the entry whose value (or code) matches key, else the first entry. Entries are copied out of PROGMEM. +static Code find_entry(const Code *table, size_t count, uint8_t key, bool match_code) { + Code entry{}; + for (size_t i = 0; i < count; i++) { + progmem_memcpy(&entry, &table[i], sizeof(entry)); + if ((match_code ? entry.code : entry.value) == key) + return entry; + } + progmem_memcpy(&entry, table, sizeof(entry)); + return entry; +} +template static uint8_t to_code(const Code (&table)[N], uint8_t value) { + return find_entry(table, N, value, false).code; +} +template static T from_code(const Code (&table)[N], uint8_t code) { + return static_cast(find_entry(table, N, code, true).value); +} + +// Inverted count of the one bits in a section, skipping the two nibbles that hold the checksum itself. +// See https://github.com/crankyoldgit/IRremoteESP8266/issues/1538#issuecomment-894645947 +static uint8_t section_checksum(const uint8_t *section) { + uint32_t ones = + __builtin_popcount(section[0]) + __builtin_popcount(section[1] & 0x0F) + __builtin_popcount(section[2] & 0xF0); + for (uint8_t i = 3; i < SAMSUNG_AC_SECTION_LENGTH; i++) { + ones += __builtin_popcount(section[i]); + } + return ~static_cast(ones); +} +static uint8_t stored_checksum(const uint8_t *section) { return (section[1] >> 4) | (section[2] << 4); } +static void store_checksum(uint8_t *section) { + const uint8_t sum = section_checksum(section); + section[1] = (section[1] & 0x0F) | (sum << 4); + section[2] = (section[2] & 0xF0) | (sum >> 4); +} + +SamsungClimate::SamsungClimate() + : climate_ir::ClimateIR( + SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}) { + progmem_memcpy(this->state_.data(), RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + // The unit's power state is unknown at boot, so the first "on" command sends a power frame + set_field(this->state_.data(), POWER_1, 0); + set_field(this->state_.data(), POWER_2, 0); +} + +void SamsungClimate::transmit_state() { + uint8_t *state = this->state_.data(); + const bool was_on = is_powered_on(state); + const bool on = this->mode != climate::CLIMATE_MODE_OFF; + + if (on) { + progmem_memcpy(state, RESET_STATE, SAMSUNG_AC_STATE_LENGTH); + set_field(state, MODE, to_code(MODES, this->mode)); + } // else keep the last mode so the power off frame still carries a valid state + + const uint8_t temp = clamp(this->target_temperature, SAMSUNG_AC_TEMP_MIN, SAMSUNG_AC_TEMP_MAX); + set_field(state, TEMP, temp - SAMSUNG_AC_TEMP_MIN); + set_field(state, SWING, to_code(SWINGS, this->swing_mode)); + set_field(state, FAN, to_code(FANS, this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO))); + set_field(state, POWER_1, on ? POWER_ON : 0); + set_field(state, POWER_2, on ? POWER_ON : 0); + + if (on != was_on) { + // A power change is sent as an extended frame with the timer block between the two state sections + uint8_t timer[SAMSUNG_AC_SECTION_LENGTH]; + progmem_memcpy(timer, TIMER_SECTION, SAMSUNG_AC_SECTION_LENGTH); + const uint8_t *sections[] = {state, timer, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 3); + } else { + const uint8_t *sections[] = {state, state + SAMSUNG_AC_SECTION_LENGTH}; + this->send_sections_(sections, 2); + } +} + +void SamsungClimate::send_sections_(const uint8_t *const *sections, uint8_t count) { + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + // Header(2) + (sections - 1) * separator(4) + bits(2 each) + trailing mark(1) + data->reserve(2 + (count - 1) * 4 + count * SAMSUNG_AC_SECTION_LENGTH * 8 * 2 + 1); + data->set_carrier_frequency(CARRIER_FREQUENCY_HZ); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + + for (uint8_t s = 0; s < count; s++) { + if (s != 0) { + data->item(BIT_MARK_US, SECTION_SPACE_US); + data->item(HEADER_MARK_US, HEADER_SPACE_US); + } + uint8_t section[SAMSUNG_AC_SECTION_LENGTH]; + memcpy(section, sections[s], SAMSUNG_AC_SECTION_LENGTH); + store_checksum(section); + for (uint8_t byte : section) { + for (uint8_t bit = 0; bit < 8; bit++, byte >>= 1) { + data->item(BIT_MARK_US, (byte & 1) ? ONE_SPACE_US : ZERO_SPACE_US); + } + } + } + + data->mark(BIT_MARK_US); + transmit.perform(); +} + +bool SamsungClimate::on_receive(remote_base::RemoteReceiveData data) { + if (!data.expect_item(HEADER_MARK_US, HEADER_SPACE_US)) { + return false; + } + + // Decode into a local buffer so a bad frame leaves the stored state untouched + uint8_t frame[EXTENDED_LENGTH] = {0}; + uint8_t length = EXTENDED_LENGTH; + for (uint8_t i = 0; i < EXTENDED_LENGTH; i++) { + if (i != 0 && i % SAMSUNG_AC_SECTION_LENGTH == 0) { + const bool have_separator = + data.expect_item(BIT_MARK_US, SECTION_SPACE_US) && data.expect_item(HEADER_MARK_US, HEADER_SPACE_US); + if (!have_separator) { + // A third section is optional; a missing separator after the second one ends the frame + if (i >= SAMSUNG_AC_STATE_LENGTH) { + length = i; + break; + } + ESP_LOGW(TAG, "Missing section separator before byte %u", i); + return false; + } + } + for (uint8_t bit = 0; bit < 8; bit++) { + if (data.expect_item(BIT_MARK_US, ONE_SPACE_US)) { + frame[i] |= 1 << bit; + } else if (!data.expect_item(BIT_MARK_US, ZERO_SPACE_US)) { + ESP_LOGW(TAG, "Bad bit %u in byte %u", bit, i); + return false; + } + } + } + if (!data.expect_mark(BIT_MARK_US)) { + ESP_LOGVV(TAG, "Footer fail"); + return false; + } + for (uint8_t offset = 0; offset < length; offset += SAMSUNG_AC_SECTION_LENGTH) { + if (stored_checksum(frame + offset) != section_checksum(frame + offset)) { + ESP_LOGW(TAG, "Checksum mismatch in section %u", offset / SAMSUNG_AC_SECTION_LENGTH); + return false; + } + } + + // In an extended frame the middle section is the timer block and the last one the second state section + uint8_t *state = this->state_.data(); + memcpy(state, frame, SAMSUNG_AC_SECTION_LENGTH); + memcpy(state + SAMSUNG_AC_SECTION_LENGTH, frame + length - SAMSUNG_AC_SECTION_LENGTH, SAMSUNG_AC_SECTION_LENGTH); + + this->swing_mode = from_code(SWINGS, get_field(state, SWING)); + this->target_temperature = get_field(state, TEMP) + SAMSUNG_AC_TEMP_MIN; + this->fan_mode = from_code(FANS, get_field(state, FAN)); + this->mode = + is_powered_on(state) ? from_code(MODES, get_field(state, MODE)) : climate::CLIMATE_MODE_OFF; + + ESP_LOGD(TAG, "Received %u byte frame, power %s", length, ONOFF(is_powered_on(state))); + this->publish_state(); + return true; +} + +} // namespace esphome::samsung diff --git a/esphome/components/samsung/samsung.h b/esphome/components/samsung/samsung.h new file mode 100644 index 0000000000..608cffd028 --- /dev/null +++ b/esphome/components/samsung/samsung.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/components/climate/climate_mode.h" +#include "esphome/components/climate_ir/climate_ir.h" + +#include + +namespace esphome::samsung { + +static constexpr uint8_t SAMSUNG_AC_TEMP_MIN = 16; +static constexpr uint8_t SAMSUNG_AC_TEMP_MAX = 30; +// A frame is made of 7 byte sections; the normal state is two sections and a power change adds a timer section +static constexpr uint8_t SAMSUNG_AC_SECTION_LENGTH = 7; +static constexpr uint8_t SAMSUNG_AC_STATE_LENGTH = 2 * SAMSUNG_AC_SECTION_LENGTH; + +class SamsungClimate : public climate_ir::ClimateIR { + public: + SamsungClimate(); + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + /// Send the given sections as one frame, each with its checksum filled in + void send_sections_(const uint8_t *const *sections, uint8_t count); + + // The last state sent or received; its power bits say whether the next frame has to switch power + std::array state_{}; +}; + +} // namespace esphome::samsung diff --git a/esphome/components/scd30/__init__.py b/esphome/components/scd30/__init__.py index e69de29bb2..71e20c7e13 100644 --- a/esphome/components/scd30/__init__.py +++ b/esphome/components/scd30/__init__.py @@ -0,0 +1 @@ +DOMAIN = "scd30" 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/scd30.cpp b/esphome/components/scd30/scd30.cpp index a780297756..5802503627 100644 --- a/esphome/components/scd30/scd30.cpp +++ b/esphome/components/scd30/scd30.cpp @@ -8,7 +8,9 @@ namespace esphome::scd30 { -static const char *const TAG = "scd30"; +ESPHOME_LOG_TAG(TAG, "scd30"); + +static constexpr uint32_t STATUS_CHECK_INTERVAL_ID = 0; static const uint16_t SCD30_CMD_GET_FIRMWARE_VERSION = 0xd100; static const uint16_t SCD30_CMD_START_CONTINUOUS_MEASUREMENTS = 0x0010; @@ -107,7 +109,7 @@ void SCD30Component::setup() { } // check each 500ms if data is ready, and read it in that case - this->set_interval("status-check", 500, [this]() { + this->set_interval(STATUS_CHECK_INTERVAL_ID, 500, [this]() { if (this->is_data_ready_()) this->update(); }); 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/__init__.py b/esphome/components/scd4x/__init__.py index e69de29bb2..338bfdf59b 100644 --- a/esphome/components/scd4x/__init__.py +++ b/esphome/components/scd4x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "scd4x" diff --git a/esphome/components/scd4x/automation.h b/esphome/components/scd4x/automation.h deleted file mode 100644 index e0cc04e2cb..0000000000 --- a/esphome/components/scd4x/automation.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "scd4x.h" - -namespace esphome::scd4x { - -template -class PerformForcedCalibrationAction final : public Action, public Parented { - public: - // User provided, not "= default": `new(p) PerformForcedCalibrationAction()` would zero-fill .bss that is already - // zero. - PerformForcedCalibrationAction() {} - - void play(const Ts &...x) override { - if (this->value_.has_value()) { - this->parent_->perform_forced_calibration(this->value_.value(x...)); - } - } - - protected: - TEMPLATABLE_VALUE(uint16_t, value) -}; - -template class FactoryResetAction final : public Action, public Parented { - public: - void play(const Ts &...x) override { this->parent_->factory_reset(); } -}; - -} // namespace esphome::scd4x diff --git a/esphome/components/scd4x/scd4x.cpp b/esphome/components/scd4x/scd4x.cpp index d9a2439bb9..8249a6feb1 100644 --- a/esphome/components/scd4x/scd4x.cpp +++ b/esphome/components/scd4x/scd4x.cpp @@ -4,7 +4,7 @@ namespace esphome::scd4x { -static const char *const TAG = "scd4x"; +ESPHOME_LOG_TAG(TAG, "scd4x"); static const uint16_t SCD41_ID = 0x1408; static const uint16_t SCD40_ID = 0x440; 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 6c16d002ad..48a208b7f7 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -6,17 +6,16 @@ from esphome.const import CONF_ID, CONF_MODE, CONF_PARAMETERS, CONF_RESTART from esphome.core import CORE, EsphomeError CODEOWNERS = ["@esphome/core"] +DOMAIN = "script" 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) SingleScript = script_ns.class_("SingleScript", Script) RestartScript = script_ns.class_("RestartScript", Script) QueueingScript = script_ns.class_("QueueingScript", Script, cg.Component) ParallelScript = script_ns.class_("ParallelScript", Script) -CONF_SCRIPT = "script" CONF_SINGLE = "single" CONF_QUEUED = "queued" CONF_PARALLEL = "parallel" @@ -36,7 +35,7 @@ PARAMETER_TYPE_TRANSLATIONS = { def get_script(script_id): - scripts = CORE.config.get(CONF_SCRIPT, {}) + scripts = CORE.config.get(DOMAIN, {}) for script in scripts: if script.get(CONF_ID, None) == script_id: return script @@ -206,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( diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp index bfe96e1b23..7174a9a0b4 100644 --- a/esphome/components/script/script.cpp +++ b/esphome/components/script/script.cpp @@ -3,7 +3,7 @@ namespace esphome::script { -static const char *const TAG = "script"; +ESPHOME_LOG_TAG(TAG, "script"); void ScriptLogger::esp_log_(int level, int line, ProgmemStr format, const char *param) { esp_log_printf_(level, TAG, line, format, param); diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index d83555016d..f00ad7f4af 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -254,17 +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_; + // Empty tuple for scripts without parameters; takes no space then + [[no_unique_address]] Args args_; }; /** Wait for a script to finish before continuing. @@ -315,7 +306,7 @@ template class ScriptWaitAction final : public Actionparam_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, std::make_index_sequence{}); + std::apply([this](auto &...args) { this->play_next_(args...); }, params); this->param_queue_.pop_front(); } else { // Queue is now empty - disable loop until next play_complex @@ -332,12 +323,8 @@ template class ScriptWaitAction final : public Action void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { - this->play_next_(std::get(tuple)...); - } - C *script_; - std::list> param_queue_; + std::list...>> param_queue_; }; } // namespace esphome::script diff --git a/esphome/components/sdl/__init__.py b/esphome/components/sdl/__init__.py index 872d831850..a8a2391a3c 100644 --- a/esphome/components/sdl/__init__.py +++ b/esphome/components/sdl/__init__.py @@ -1,6 +1,7 @@ import esphome.codegen as cg CODEOWNERS = ["@clydebarrow"] +DOMAIN = "sdl" SDL_KeyCode = cg.global_ns.enum("SDL_KeyCode") diff --git a/esphome/components/sdm_meter/__init__.py b/esphome/components/sdm_meter/__init__.py index e69de29bb2..b13d6e18b6 100644 --- a/esphome/components/sdm_meter/__init__.py +++ b/esphome/components/sdm_meter/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sdm_meter" diff --git a/esphome/components/sdm_meter/sdm_meter.cpp b/esphome/components/sdm_meter/sdm_meter.cpp index c1b359cc97..5389325cc2 100644 --- a/esphome/components/sdm_meter/sdm_meter.cpp +++ b/esphome/components/sdm_meter/sdm_meter.cpp @@ -6,7 +6,7 @@ namespace esphome::sdm_meter { namespace helpers = modbus::helpers; -static const char *const TAG = "sdm_meter"; +ESPHOME_LOG_TAG(TAG, "sdm_meter"); static const uint8_t MODBUS_REGISTER_COUNT = 80; // 80 x 16-bit registers (40 float values) 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/sdp3x/__init__.py b/esphome/components/sdp3x/__init__.py index e69de29bb2..50ffb263bf 100644 --- a/esphome/components/sdp3x/__init__.py +++ b/esphome/components/sdp3x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sdp3x" diff --git a/esphome/components/sdp3x/sdp3x.cpp b/esphome/components/sdp3x/sdp3x.cpp index 7fcd47a265..98b5d9940a 100644 --- a/esphome/components/sdp3x/sdp3x.cpp +++ b/esphome/components/sdp3x/sdp3x.cpp @@ -5,7 +5,7 @@ namespace esphome::sdp3x { -static const char *const TAG = "sdp3x.sensor"; +ESPHOME_LOG_TAG(TAG, "sdp3x.sensor"); static const uint16_t SDP3X_SOFT_RESET = 0x0006; static const uint16_t SDP3X_READ_ID1 = 0x367C; static const uint16_t SDP3X_READ_ID2 = 0xE102; diff --git a/esphome/components/sds011/__init__.py b/esphome/components/sds011/__init__.py index e69de29bb2..386bc61be5 100644 --- a/esphome/components/sds011/__init__.py +++ b/esphome/components/sds011/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sds011" diff --git a/esphome/components/sds011/sds011.cpp b/esphome/components/sds011/sds011.cpp index dfc7857266..147365251b 100644 --- a/esphome/components/sds011/sds011.cpp +++ b/esphome/components/sds011/sds011.cpp @@ -4,7 +4,7 @@ namespace esphome::sds011 { -static const char *const TAG = "sds011"; +ESPHOME_LOG_TAG(TAG, "sds011"); static const uint8_t SDS011_MSG_REQUEST_LENGTH = 19; static const uint8_t SDS011_MSG_RESPONSE_LENGTH = 10; @@ -115,11 +115,7 @@ void SDS011Component::sds011_write_command_(const uint8_t *command_data) { } uint8_t SDS011Component::sds011_checksum_(const uint8_t *command_data, uint8_t length) const { - uint8_t sum = 0; - for (uint8_t i = 0; i < length; i++) { - sum += command_data[i]; - } - return sum; + return sum_bytes(command_data, length); } optional SDS011Component::check_byte_() const { 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/__init__.py b/esphome/components/seeed_mr24hpc1/__init__.py index 56630f18f4..4ec01e46c6 100644 --- a/esphome/components/seeed_mr24hpc1/__init__.py +++ b/esphome/components/seeed_mr24hpc1/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["uart"] # is the code owner of the relevant code base CODEOWNERS = ["@limengdu"] +DOMAIN = "seeed_mr24hpc1" # The current component or platform can be configured or defined multiple times in the same configuration file. MULTI_CONF = True 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/seeed_mr24hpc1.cpp b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp index a0dc7ae4cb..8d5103cbba 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.cpp @@ -7,7 +7,9 @@ namespace esphome::seeed_mr24hpc1 { -static const char *const TAG = "seeed_mr24hpc1"; +ESPHOME_LOG_TAG(TAG, "seeed_mr24hpc1"); + +static constexpr uint32_t STARTUP_DELAY_MS = 1000; // Prints the component's configuration data. dump_config() prints all of the component's configuration // items in an easy-to-read format, including the configuration key-value pairs. @@ -76,8 +78,7 @@ void MR24HPC1Component::setup() { this->custom_mode_end_text_sensor_->publish_state("Not in custom mode"); } #endif - this->set_custom_end_mode(); - this->poll_time_base_func_check_ = true; + this->poll_time_base_func_check_ = false; this->check_dev_inf_sign_ = true; this->sg_start_query_data_ = STANDARD_FUNCTION_QUERY_PRODUCT_MODE; this->sg_data_len_ = 0; @@ -92,7 +93,13 @@ void MR24HPC1Component::setup() { memset(this->sg_frame_prase_buf_, 0, FRAME_BUF_MAX_SIZE); memset(this->sg_frame_buf_, 0, FRAME_BUF_MAX_SIZE); - this->set_interval(8000, [this]() { this->update_(); }); + // The module locks up when queried during its own boot, so hold all TX for a second + this->set_timeout(STARTUP_DELAY_MS, [this]() { + this->module_ready_ = true; + this->set_custom_end_mode(); + this->poll_time_base_func_check_ = true; + this->set_interval(8000, [this]() { this->update_(); }); + }); } // Timed polling of radar data @@ -251,17 +258,11 @@ void MR24HPC1Component::loop() { } } -// Calculate CRC check digit -static uint8_t get_frame_crc_sum(const uint8_t *data, int len) { - unsigned int crc_sum = 0; - for (int i = 0; i < len - 3; i++) { - crc_sum += data[i]; - } - return crc_sum & 0xff; -} +// Frames are at least 10 bytes, so the three trailing bytes can always be left out +static uint8_t get_frame_crc_sum(const uint8_t *data, size_t len) { return sum_bytes(data, len - 3); } // Check that the check digit is correct -static int get_frame_check_status(uint8_t *data, int len) { +static int get_frame_check_status(const uint8_t *data, size_t len) { uint8_t crc_sum = get_frame_crc_sum(data, len); uint8_t verified = data[len - 3]; return (verified == crc_sum) ? 1 : 0; @@ -686,6 +687,8 @@ void MR24HPC1Component::r24_frame_parse_human_information_(uint8_t *data) { // Sending data frames void MR24HPC1Component::send_query_(const uint8_t *query, size_t string_length) { + if (!this->module_ready_) + return; this->write_array(query, string_length); } diff --git a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h index b231bab33e..23cecfc986 100644 --- a/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h +++ b/esphome/components/seeed_mr24hpc1/seeed_mr24hpc1.h @@ -153,6 +153,7 @@ class MR24HPC1Component final : public Component, int sg_start_query_data_; bool check_dev_inf_sign_; bool poll_time_base_func_check_; + bool module_ready_{false}; void update_(); void r24_split_data_frame_(uint8_t value); 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/__init__.py b/esphome/components/seeed_mr60bha2/__init__.py index 6bf8657af9..7a85567510 100644 --- a/esphome/components/seeed_mr60bha2/__init__.py +++ b/esphome/components/seeed_mr60bha2/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] +DOMAIN = "seeed_mr60bha2" MULTI_CONF = True mr60bha2_ns = cg.esphome_ns.namespace("seeed_mr60bha2") 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/seeed_mr60bha2.cpp b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp index cbf0415f6c..c5d1292458 100644 --- a/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +++ b/esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp @@ -7,7 +7,7 @@ namespace esphome::seeed_mr60bha2 { -static const char *const TAG = "seeed_mr60bha2"; +ESPHOME_LOG_TAG(TAG, "seeed_mr60bha2"); // Maximum bytes to log in verbose hex output static constexpr size_t MR60BHA2_MAX_LOG_BYTES = 64; 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/__init__.py b/esphome/components/seeed_mr60fda2/__init__.py index 159a1ece9c..cc22741583 100644 --- a/esphome/components/seeed_mr60fda2/__init__.py +++ b/esphome/components/seeed_mr60fda2/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@limengdu"] DEPENDENCIES = ["uart"] +DOMAIN = "seeed_mr60fda2" MULTI_CONF = True mr60fda2_ns = cg.esphome_ns.namespace("seeed_mr60fda2") 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/seeed_mr60fda2.cpp b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp index 2d1cd0fbb4..332c59061b 100644 --- a/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp +++ b/esphome/components/seeed_mr60fda2/seeed_mr60fda2.cpp @@ -7,7 +7,7 @@ namespace esphome::seeed_mr60fda2 { -static const char *const TAG = "seeed_mr60fda2"; +ESPHOME_LOG_TAG(TAG, "seeed_mr60fda2"); // Maximum bytes to log in verbose hex output static constexpr size_t MR60FDA2_MAX_LOG_BYTES = 64; 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/__init__.py b/esphome/components/selec_meter/__init__.py index e69de29bb2..469119b8ac 100644 --- a/esphome/components/selec_meter/__init__.py +++ b/esphome/components/selec_meter/__init__.py @@ -0,0 +1 @@ +DOMAIN = "selec_meter" diff --git a/esphome/components/selec_meter/selec_meter.cpp b/esphome/components/selec_meter/selec_meter.cpp index 3ad1f8b87c..a11cc6a008 100644 --- a/esphome/components/selec_meter/selec_meter.cpp +++ b/esphome/components/selec_meter/selec_meter.cpp @@ -6,7 +6,7 @@ namespace esphome::selec_meter { namespace helpers = modbus::helpers; -static const char *const TAG = "selec_meter"; +ESPHOME_LOG_TAG(TAG, "selec_meter"); static const uint8_t MODBUS_REGISTER_COUNT = 34; // 34 x 16-bit registers diff --git a/esphome/components/select/__init__.py b/esphome/components/select/__init__.py index 6b7461bfa8..7b9f616460 100644 --- a/esphome/components/select/__init__.py +++ b/esphome/components/select/__init__.py @@ -15,31 +15,27 @@ from esphome.const import ( CONF_OPERATION, CONF_OPTION, CONF_OPTIONS, - CONF_TRIGGER_ID, CONF_WEB_SERVER, ) 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"] +DOMAIN = "select" IS_PLATFORM_COMPONENT = True select_ns = cg.esphome_ns.namespace("select") Select = select_ns.class_("Select", cg.EntityBase) SelectPtr = Select.operator("ptr") -# Triggers -SelectStateTrigger = select_ns.class_( - "SelectStateTrigger", - automation.Trigger.template(cg.StringRef, cg.size_t), -) - # Conditions SelectIsCondition = select_ns.class_("SelectIsCondition", automation.Condition) @@ -60,11 +56,7 @@ _SELECT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSelectComponent), cv.GenerateID(): cv.declare_id(Select), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SelectStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation(), } ) ) @@ -93,12 +85,21 @@ 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) - await automation.build_automation( - trigger, [(cg.StringRef, "x"), (cg.size_t, "i")], conf + # The callback carries the index; the automation gets the option text as well. + parent = automation.parent_ref(var) + index = cg.RawExpression("index") + await automation.build_callback_automation( + var, + "add_on_state_callback", + [(cg.StringRef, "x"), (cg.size_t, "i")], + conf, + params=[(cg.size_t, "index")], + forward=[cg.StringRef(parent.option_at(index)), index], ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: @@ -123,6 +124,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) @@ -178,14 +186,8 @@ automation.register_apply_action( 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 diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index c2dc752ca5..9bef819e6d 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -6,17 +6,6 @@ namespace esphome::select { -class SelectStateTrigger final : public Trigger { - public: - explicit SelectStateTrigger(Select *parent) : parent_(parent) { - parent->add_on_state_callback( - [this](size_t index) { this->trigger(StringRef(this->parent_->option_at(index)), index); }); - } - - protected: - Select *parent_; -}; - 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.cpp b/esphome/components/select/select.cpp index 05a0ee1ed9..4d77c6d6f6 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -6,7 +6,7 @@ namespace esphome::select { -static const char *const TAG = "select"; +ESPHOME_LOG_TAG(TAG, "select"); void Select::publish_state(const char *state) { auto index = this->index_of(state); diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index 9d2fb725f3..167e6769a4 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -4,7 +4,7 @@ namespace esphome::select { -static const char *const TAG = "select"; +ESPHOME_LOG_TAG(TAG, "select"); SelectCall &SelectCall::set_option(const char *option, size_t len) { return this->with_option(option, len); } 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/sen0321/__init__.py b/esphome/components/sen0321/__init__.py index 458ffa67f9..905618bfba 100644 --- a/esphome/components/sen0321/__init__.py +++ b/esphome/components/sen0321/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@notjj"] +DOMAIN = "sen0321" diff --git a/esphome/components/sen0321/sen0321.cpp b/esphome/components/sen0321/sen0321.cpp index e074934d0f..564e05e039 100644 --- a/esphome/components/sen0321/sen0321.cpp +++ b/esphome/components/sen0321/sen0321.cpp @@ -4,7 +4,7 @@ namespace esphome::sen0321_sensor { -static const char *const TAG = "sen0321_sensor.sensor"; +ESPHOME_LOG_TAG(TAG, "sen0321_sensor.sensor"); void Sen0321Sensor::setup() { if (!this->write_byte(SENSOR_MODE_REGISTER, SENSOR_MODE_AUTO)) { diff --git a/esphome/components/sen21231/__init__.py b/esphome/components/sen21231/__init__.py index e69de29bb2..c2802220f6 100644 --- a/esphome/components/sen21231/__init__.py +++ b/esphome/components/sen21231/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sen21231" diff --git a/esphome/components/sen21231/sen21231.cpp b/esphome/components/sen21231/sen21231.cpp index 3f6212e7f2..29175d845c 100644 --- a/esphome/components/sen21231/sen21231.cpp +++ b/esphome/components/sen21231/sen21231.cpp @@ -3,7 +3,7 @@ namespace esphome::sen21231_sensor { -static const char *const TAG = "sen21231_sensor.sensor"; +ESPHOME_LOG_TAG(TAG, "sen21231_sensor.sensor"); void Sen21231Sensor::update() { this->read_data_(); } diff --git a/esphome/components/sen5x/__init__.py b/esphome/components/sen5x/__init__.py index e69de29bb2..e58c474fae 100644 --- a/esphome/components/sen5x/__init__.py +++ b/esphome/components/sen5x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sen5x" 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/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index f8df89ee33..f192a40288 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -7,7 +7,7 @@ namespace esphome::sen5x { -static const char *const TAG = "sen5x"; +ESPHOME_LOG_TAG(TAG, "sen5x"); static const uint16_t SEN5X_CMD_AUTO_CLEANING_INTERVAL = 0x8004; static const uint16_t SEN5X_CMD_GET_DATA_READY_STATUS = 0x0202; 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/sen6x/__init__.py b/esphome/components/sen6x/__init__.py index e69de29bb2..abd5e7e50c 100644 --- a/esphome/components/sen6x/__init__.py +++ b/esphome/components/sen6x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sen6x" diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index ed6cb24c52..91762e0f07 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -5,7 +5,7 @@ namespace esphome::sen6x { -static const char *const TAG = "sen6x"; +ESPHOME_LOG_TAG(TAG, "sen6x"); static constexpr uint8_t POLL_RETRIES = 24; // 24 attempts static constexpr uint32_t I2C_READ_DELAY = 20; // 20 ms to wait for I2C read to complete diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 49bee10936..c557a7960d 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,9 +1,12 @@ from dataclasses import dataclass, field +import logging +from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32, mdns, network, psram, socket, wifi -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER +from esphome.components.mdns import DOMAIN as MDNS_DOMAIN import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -11,7 +14,6 @@ from esphome.const import ( CONF_FORMAT, CONF_HEIGHT, CONF_ID, - CONF_MDNS, CONF_MODEL, CONF_NAME, CONF_PROJECT, @@ -21,10 +23,12 @@ from esphome.const import ( CONF_VERSION, CONF_WIDTH, ) -from esphome.core import CORE, ID -from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.core import CORE +from esphome.cpp_generator import MockObj from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + # mdns for autodiscovery AUTO_LOAD = ["mdns"] CODEOWNERS = ["@kahrendt"] @@ -46,6 +50,33 @@ CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" CONF_CODECS = "codecs" +CONF_STATIC_PAIRING_CODE = "static_pairing_code" +CONF_UNPAIRED_ACCESS = "unpaired_access" +CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window" +CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window" +CONF_ON_DISPLAY_PAIRING_CODE = "on_display_pairing_code" +CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code" +CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded" +CONF_ON_PAIRING_FAILED = "on_pairing_failed" + +# A static pairing code is exactly 8 decimal digits. +STATIC_PAIRING_CODE_DIGITS = 8 + + +def _validate_static_pairing_code(value: Any) -> str: + # string_strict so leading zeros survive and `!secret` works. + value = cv.string_strict(value) + if len(value) != STATIC_PAIRING_CODE_DIGITS or not ( + value.isascii() and value.isdigit() + ): + raise cv.Invalid( + f"{CONF_STATIC_PAIRING_CODE} must be exactly " + f"{STATIC_PAIRING_CODE_DIGITS} decimal digits " + '(quote the value so leading zeros are preserved, e.g. "01234567")' + ) + return value + + # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -76,7 +107,6 @@ OPUS_SAMPLE_RATE = 48000 SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") -IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") @@ -109,10 +139,31 @@ SendspinHub = sendspin_ns.class_( ) -SendspinSwitchCommandAction = sendspin_ns.class_( - "SendspinSwitchCommandAction", - automation.Action, - cg.Parented.template(SendspinHub), +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_OPEN_PAIRING_WINDOW, "add_on_open_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_DISPLAY_PAIRING_CODE, + "add_on_display_pairing_code_callback", + [(cg.std_string, "code")], + ), + automation.CallbackAutomation( + CONF_ON_CLEAR_PAIRING_CODE, "add_on_clear_pairing_code_callback" + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_SUCCEEDED, + "add_on_pairing_succeeded_callback", + [(cg.std_string, "server_id")], + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_FAILED, + "add_on_pairing_failed_callback", + [(cg.std_string, "server_id"), (cg.StringRef, "reason")], + ), ) @@ -123,6 +174,8 @@ class SendspinConfiguration: metadata_support: bool = False player_support: bool = False visualizer_support: bool = False + pairing_code_display_support: bool = False + switch_types: set[str] = field(default_factory=set) artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -159,6 +212,16 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def request_pairing_code_display_support() -> None: + """Mark that the device can emit a dynamic pairing code (e.g. a pairing_code text sensor).""" + _get_data().pairing_code_display_support = True + + +def request_switch(switch_type: str) -> None: + """Mark that a sendspin switch of this type drives the matching hub setting.""" + _get_data().switch_types.add(switch_type) + + def register_artwork_preference(config: ConfigType) -> int: """Register an artwork slot preference and return the slot it was given. @@ -215,46 +278,106 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, + cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( + _validate_static_pairing_code + ), + # No default: the unpaired access switch rejects this key when it is set. + cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, + cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_DISPLAY_PAIRING_CODE): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}), + cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}), + cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}), } ), cv.only_on_esp32, + # sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 cannot use. + cv.only_with_framework("esp-idf"), _request_high_performance_networking, ) +def _offers_dynamic_pairing_code(config: ConfigType) -> bool: + """Whether the device can show a dynamic pairing code, so dynamic_pairing_code is advertised.""" + return bool( + config.get(CONF_ON_DISPLAY_PAIRING_CODE) + or _get_data().pairing_code_display_support + ) + + +def _has_pairing_method(config: ConfigType) -> bool: + """Whether the config gives a server any way to pair with the device.""" + return CONF_STATIC_PAIRING_CODE in config or _offers_dynamic_pairing_code(config) + + +def _final_validate(config: ConfigType) -> ConfigType: + dynamic_code = _offers_dynamic_pairing_code(config) + # The protocol allows only one pairing code method. + if dynamic_code and CONF_STATIC_PAIRING_CODE in config: + raise cv.Invalid( + f"'{CONF_STATIC_PAIRING_CODE}' cannot be used with a dynamic pairing code " + f"({CONF_ON_DISPLAY_PAIRING_CODE} or a pairing_code text sensor), since only " + "one pairing code method can be offered", + path=[CONF_STATIC_PAIRING_CODE], + ) + if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): + _LOGGER.warning( + "'%s' is off but there is no pairing method (%s or a dynamic pairing code), so no " + "new server can pair with this device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _request_controller_role(config: ConfigType) -> ConfigType: """Request the controller role for the sendspin.switch action.""" request_controller_support() return config +# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. +SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinHub), + } + ) +) + SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( - automation.maybe_simple_id( - cv.Schema( - { - cv.GenerateID(): cv.use_id(SendspinHub), - } - ) - ), - _request_controller_role, + SENDSPIN_HUB_ACTION_SCHEMA, _request_controller_role ) -@automation.register_action( +automation.register_apply_action( "sendspin.switch", - SendspinSwitchCommandAction, SENDSPIN_SIMPLE_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("switch_client()"), +) + + +automation.register_apply_action( + "sendspin.confirm_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("confirm_pairing_window()"), +) + +automation.register_apply_action( + "sendspin.cancel_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("cancel_pairing_window()"), ) -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: @@ -281,20 +404,38 @@ async def to_code(config: ConfigType) -> None: if value: cg.add(setter(value)) - # sendspin-cpp library - esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0") + if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None: + cg.add(var.set_static_pairing_code(code)) - cg.add_define("USE_SENDSPIN", True) # for MDNS + # The client starts once both are set: here, or by the switch that drives the setting. + switch_types = _get_data().switch_types + if CONF_ENABLED not in switch_types: + cg.add(var.set_enabled(True)) + if CONF_UNPAIRED_ACCESS not in switch_types: + cg.add(var.set_unpaired_access_enabled(config.get(CONF_UNPAIRED_ACCESS, True))) + + if _offers_dynamic_pairing_code(config): + cg.add(var.set_pairing_code_display_supported(True)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + # sendspin-cpp library + esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3") + # esp_websocket_client links esp_tls even for ws:// connections. + esp32.request_tls() + + cg.add_define("USE_SENDSPIN", True) # for MDNS and the native API # 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]) + mdns_var = await cg.get_variable(CORE.config[MDNS_DOMAIN][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. + # The color and source roles are not yet wired up in ESPHome; disable them in the library for now. esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False) + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False) # Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*) # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). @@ -368,7 +509,8 @@ async def to_code(config: ConfigType) -> None: ("audio_formats", audio_format_structs), ("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]), ("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]), - ("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), + # The released YAML key name is kept for compatibility. + ("initial_output_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]), ("psram_stack", psram_stack), ] if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None: @@ -383,6 +525,9 @@ async def to_code(config: ConfigType) -> None: else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False) + if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]: + esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False) + if data.visualizer_support: cg.add_define("USE_SENDSPIN_VISUALIZER", True) else: 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..a5336d5e05 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -1,5 +1,7 @@ """Sendspin image platform.""" +from typing import Any + from esphome import automation import esphome.codegen as cg from esphome.components import runtime_image @@ -16,13 +18,11 @@ from esphome.const import ( CONF_WIDTH, ) from esphome.core import ID -from esphome.cpp_generator import TemplateArgsType from esphome.types import ConfigType from .. import ( CONF_DISPLAY_OFFSET, CONF_SENDSPIN_ID, - IMAGE_FORMAT_BMP, IMAGE_FORMAT_JPEG, IMAGE_FORMAT_PNG, IMAGE_SOURCE_ALBUM, @@ -58,9 +58,16 @@ _FORMAT_TO_SENDSPIN_ENUM = { "JPEG": IMAGE_FORMAT_JPEG, "JPG": IMAGE_FORMAT_JPEG, "PNG": IMAGE_FORMAT_PNG, - "BMP": IMAGE_FORMAT_BMP, } + +# Remove before 2027.4.0 +def _reject_bmp(value: Any) -> Any: + if isinstance(value, str) and value.upper() == "BMP": + raise cv.Invalid("BMP artwork is no longer supported, use JPEG or PNG instead.") + return value + + # The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it # would never receive artwork while still paying for two frame buffers, so it is not offered here. IMAGE_SOURCES = { @@ -122,7 +129,9 @@ CONFIG_SCHEMA = cv.All( cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), # Narrow runtime_image's format list to what the library can request, so the # accepted set and the enum map below cannot drift apart. - cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_FORMAT): cv.All( + _reject_bmp, cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True) + ), cv.Required(CONF_RESIZE): cv.dimensions, cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, @@ -198,16 +207,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 +216,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/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 558a292d5b..a93c2eb443 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -1,6 +1,6 @@ #include "sendspin_image.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK) #include "esphome/core/log.h" @@ -8,7 +8,9 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.image"; +ESPHOME_LOG_TAG(TAG, "sendspin.image"); + +static constexpr uint32_t TRANSITION_ACK_TIMEOUT_ID = 0; // How long a displayed frame may wait for sendspin.image.transition_finished before a warning // names the missing ack. Generous next to a typical fade of a second or two. @@ -125,7 +127,7 @@ bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const } // A decode that asked for other dimensions had the buffer taken away from it, so it painted - // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // nothing (or stopped partway). JPEG reports that as an error above; PNG carries on // regardless, so the frame is dropped here. return this->decode_sink_.decoded_into(target); } @@ -163,7 +165,7 @@ void SendspinImageSlot::on_display_(uint32_t lateness_ms) { // that never reaches the action stalls the slot with nothing in the log. Name the cause after // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame // that was already acked and superseded. - this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() { + this->set_timeout(TRANSITION_ACK_TIMEOUT_ID, TRANSITION_ACK_WARNING_MS, [this]() { if (this->transition_pending_) { ESP_LOGW(TAG, "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until " diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h index 2f6f4e8a4d..43dd4ed66b 100644 --- a/esphome/components/sendspin/image/sendspin_image.h +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK) #include "esphome/components/image/image.h" #include "esphome/components/runtime_image/runtime_image.h" @@ -29,7 +29,7 @@ class ArtworkDecodeSink : public runtime_image::RuntimeImage { /// @brief True when the decode ended with the given buffer still in place. /// /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions - /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// leaves the sink holding nothing. The JPEG decoder reports that as a decode error, but /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } }; diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index 59ead1bb53..8c82022f1e 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -1,6 +1,6 @@ #include "sendspin_media_player.h" -#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) +#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) #include "esphome/core/application.h" #include "esphome/core/log.h" @@ -16,7 +16,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.media_player"; +ESPHOME_LOG_TAG(TAG, "sendspin.media_player"); // THREAD CONTEXT: Main loop. The callbacks registered here also fire on the main loop, // since SendspinHub dispatches group updates and controller state from client_->loop(). diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index ff76473189..bc07254efe 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) +#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER) #include "esphome/components/media_player/media_player.h" #include "esphome/components/sendspin/sendspin_hub.h" diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6a9f1f18ba..31c454fb71 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -8,12 +8,12 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TASK_STACK_IN_PSRAM, ) -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CODEC_FLAC, CODEC_OPUS, + CODEC_PCM, CODECS, CONF_CODECS, CONF_DECODE_MEMORY, @@ -41,18 +41,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.""" @@ -72,6 +60,11 @@ def _resolve_codecs(config: ConfigType) -> ConfigType: f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", path=[CONF_CODECS], ) + if CODEC_FLAC not in codecs and CODEC_PCM not in codecs: + raise cv.Invalid( + f"Codecs must include '{CODEC_FLAC}' or '{CODEC_PCM}'", + path=[CONF_CODECS], + ) return config @@ -144,25 +137,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 c3fb1fe1cb..5e5cf18799 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -1,6 +1,6 @@ #include "sendspin_media_source.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) #include "esphome/components/audio/audio.h" #include "esphome/core/log.h" @@ -9,7 +9,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.media_source"; +ESPHOME_LOG_TAG(TAG, "sendspin.media_source"); static constexpr char URI_PREFIX[] = "sendspin://"; @@ -24,7 +24,7 @@ void SendspinMediaSource::setup() { // Push cached states to player role. They may have been set before setup() ran. this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f)); this->player_role_->update_muted(this->cached_muted_); - this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_); + this->player_role_->set_output_delay_adjustable(this->static_delay_adjustable_); } void SendspinMediaSource::dump_config() { @@ -35,7 +35,7 @@ void SendspinMediaSource::dump_config() { void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) { this->static_delay_adjustable_ = adjustable; if (this->player_role_) { - this->player_role_->set_static_delay_adjustable(adjustable); + this->player_role_->set_output_delay_adjustable(adjustable); } } @@ -102,8 +102,8 @@ void SendspinMediaSource::handle_command(media_source::MediaSourceCommand comman if (!this->pending_start_) { // Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before // play_uri - ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE"); - this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE); + ESP_LOGD(TAG, "Received STOP command, leaving the Sendspin group"); + this->parent_->leave_group(); } break; } @@ -185,8 +185,6 @@ size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_ // THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback) void SendspinMediaSource::on_stream_start() { - this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED); - if (!this->pending_start_) { // Dedup rapid on_stream_start() calls this->pending_start_ = true; @@ -211,4 +209,4 @@ void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(mute } // namespace esphome::sendspin_ -#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER +#endif // USE_ESP_IDF && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 1c5cb625bf..f0800dbcb1 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER) #include "esphome/components/sendspin/sendspin_hub.h" diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 58ec57c768..8b5eaeeed2 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -1,6 +1,6 @@ #include "sendspin_hub.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/components/network/util.h" #ifdef USE_ETHERNET @@ -17,26 +17,122 @@ #include +#include +#include + namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.hub"; +ESPHOME_LOG_TAG(TAG, "sendspin.hub"); + +namespace { + +// The reason passed to on_pairing_failed automations, in the protocol's own spelling. +StringRef pair_abort_reason_to_string(sendspin::SendspinPairAbortReason reason) { + using sendspin::SendspinPairAbortReason; + switch (reason) { + case SendspinPairAbortReason::ATTEMPT_TIMEOUT: + return StringRef::from_lit("attempt_timeout"); + case SendspinPairAbortReason::CONCURRENT_ATTEMPT: + return StringRef::from_lit("concurrent_attempt"); + case SendspinPairAbortReason::METHOD_NOT_SUPPORTED: + return StringRef::from_lit("method_not_supported"); + case SendspinPairAbortReason::PAIRING_CODE_MISMATCH: + return StringRef::from_lit("pairing_code_mismatch"); + case SendspinPairAbortReason::USER_CANCELLED: + return StringRef::from_lit("user_cancelled"); + case SendspinPairAbortReason::UNKNOWN: + break; + } + return StringRef::from_lit("unknown"); +} + +// Zeroes through a volatile pointer so dead-store elimination cannot drop it. +void secure_wipe(void *data, size_t len) { + volatile auto *p = static_cast(data); + for (size_t i = 0; i < len; ++i) { + p[i] = 0; + } +} + +// Call once per key, from setup(). +ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) { + return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name)); +} + +// Loads straight into the returned buffer, which the library wipes after use. A failed load can +// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers +// this buffer: the preference backend keeps its own copy of a pending write until it syncs. +std::optional> read_blob(ESPPreferenceObject &pref, size_t size) { + std::vector out(size); + if (!pref.load(out.data(), out.size())) { + secure_wipe(out.data(), out.size()); + return std::nullopt; + } + return out; +} + +// The library always writes a key's fixed size; any other length is misuse and is not stored. +bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) { + if (len != size) { + ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size); + return false; + } + if (!pref.save(data, len)) { + ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len); + return false; + } + return true; +} + +// "rec_" to n; nullopt for any other key or a slot with no storage here. +std::optional parse_record_slot(const std::string &key) { + const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX; + const size_t prefix_len = std::strlen(prefix); + if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) { + return std::nullopt; + } + size_t slot = 0; + for (size_t i = prefix_len; i < key.size(); i++) { + const char c = key[i]; + if (c < '0' || c > '9') { + return std::nullopt; + } + slot = slot * 10 + static_cast(c - '0'); + if (slot >= SENDSPIN_RECORD_SLOTS) { + return std::nullopt; + } + } + return slot; +} + +} // namespace #ifdef USE_SENDSPIN_ARTWORK // Indexed by the library enums, which start at zero and are contiguous. static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; -static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"}; #endif +SendspinHub *global_sendspin_hub = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +SendspinHub::SendspinHub() { global_sendspin_hub = this; } + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); - // Set up persistence (preferences must be initialized before providers are added to the client) - this->last_played_server_pref_ = - global_preferences->make_preference(fnv1a_hash("sendspin_last_played")); -#ifdef USE_SENDSPIN_PLAYER - this->static_delay_pref_ = global_preferences->make_preference(fnv1a_hash("sendspin_static_delay")); -#endif + // Set up persistence (preferences must be initialized before providers are added to the client). These key names + // are frozen; see PREFERENCE KEYS in sendspin_hub.h. + namespace keys = sendspin::persistence_keys; + this->keypair_pref_ = make_blob_pref("keypair", keys::KEYPAIR_SIZE); + this->pairing_psk_pref_ = make_blob_pref("pair_psk", keys::PAIRING_PSK_SIZE); + this->last_played_pref_ = make_blob_pref("last_played", keys::LAST_PLAYED_SIZE); + // Released firmware's name, so a calibrated delay survives. + this->output_delay_pref_ = make_blob_pref("static_delay", keys::OUTPUT_DELAY_SIZE); + for (size_t slot = 0; slot < SENDSPIN_RECORD_SLOTS; slot++) { + this->record_slot_prefs_[slot] = make_blob_pref(keys::record_slot_key(slot), keys::RECORD_SLOT_SIZE); + } + this->record_order_pref_ = make_blob_pref(keys::RECORD_ORDER, SENDSPIN_RECORD_SLOTS); // Wire providers and client listener this->client_->set_listener(this); @@ -62,14 +158,15 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif -#ifndef USE_SENDSPIN_SWITCH - this->enabled_ = true; -#endif + // Set before setup() by codegen; an unpaired access switch sets it later, from its own setup(). + if (this->unpaired_access_.has_value()) { + this->client_->set_unpaired_access_enabled(*this->unpaired_access_); + } } void SendspinHub::loop() { - if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && - !this->status_has_error()) { + if (this->enabled_.has_value() && this->unpaired_access_.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()) { @@ -83,17 +180,37 @@ void SendspinHub::loop() { #endif } +// Sends each server a goodbye and writes what the client still owed its provider. Wi-Fi sets up +// before this hub, so it shuts down after it and the goodbyes can still go out. +void SendspinHub::on_shutdown() { + // Keeps loop() from starting the client again. + this->enabled_ = false; + if (this->is_client_running()) { + this->client_->stop(); + } +} + void SendspinHub::dump_config() { + // client_id exists only once start() has run. + const char *client_id = "(unavailable)"; + if (this->client_ != nullptr && !this->client_->client_id().empty()) { + client_id = this->client_->client_id().c_str(); + } char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, "Sendspin Hub:\n" " Client ID: %s\n" + " MAC address: %s\n" " Manufacturer: %s\n" " Model: %s\n" " Firmware version: %s\n" - " Task stack in PSRAM: %s", - get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), - this->firmware_version_, YESNO(this->task_stack_in_psram_)); + " Task stack in PSRAM: %s\n" + " Unpaired access: %s\n" + " Pairing code method: %s", + client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), + this->firmware_version_, YESNO(this->task_stack_in_psram_), + YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()), + this->pairing_code_method_()); #ifdef USE_SENDSPIN_ARTWORK // Slot indices come from the order the image platform entries were declared, so the log is the @@ -108,7 +225,7 @@ void SendspinHub::dump_config() { #endif } -// THREAD CONTEXT: Main loop (invoked from Sendspin components) +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or 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", @@ -118,6 +235,23 @@ void SendspinHub::set_enabled(bool enabled) { this->enabled_ = enabled; } +// THREAD CONTEXT: Main loop +std::optional SendspinHub::get_pairing_token() const { + if (this->client_ == nullptr) { + return std::nullopt; + } + return this->client_->pairing_token(); +} + +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) +void SendspinHub::set_unpaired_access_enabled(bool enabled) { + this->unpaired_access_ = enabled; + // Before setup() there is no client yet; setup() applies the stored value. + if (this->client_ != nullptr) { + this->client_->set_unpaired_access_enabled(enabled); + } +} + #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE // THREAD CONTEXT: Main loop void SendspinHub::update_mdns_service_() { @@ -154,22 +288,58 @@ void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) } // THREAD CONTEXT: Main loop (invoked from Sendspin components) -void SendspinHub::update_state(sendspin::SendspinClientState state) { - if (this->is_client_running()) { - this->client_->update_state(state); +void SendspinHub::leave_group() { + if (this->is_client_running() && + this->client_->get_group_state().playback_state == sendspin::SendspinPlaybackState::PLAYING) { + this->client_->leave(); } } -const char *SendspinHub::get_client_id_into_buffer(std::span buf) { - // The server matches client_id against the L2 source MAC of the device's multicast traffic. +// THREAD CONTEXT: Main loop (invoked from the sendspin.confirm_pairing_window action) +void SendspinHub::confirm_pairing_window() { + if (this->is_client_running()) { + this->client_->confirm_pairing_window(); + } +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.cancel_pairing_window action) +void SendspinHub::cancel_pairing_window() { + if (this->is_client_running()) { + this->client_->cancel_pairing_window(); + } +} + +const char *SendspinHub::get_mac_address_into_buffer(std::span buf) { + // The server matches this MAC against the L2 source MAC of the device's multicast traffic. // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the // eFuse base MAC when ethernet is the active interface. #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) { - return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf); + } else { + get_mac_address_pretty_into_buffer(buf); } +#else + get_mac_address_pretty_into_buffer(buf); #endif - return get_mac_address_pretty_into_buffer(buf); + // The pretty format is uppercase, but SendspinClientConfig::mac_address must be lowercase. + for (char &c : buf) { + if (c >= 'A' && c <= 'F') { + c += 'a' - 'A'; + } + } + return buf.data(); +} + +// Validation rejects a static code together with the dynamic code, so at most one is set. +const char *SendspinHub::pairing_code_method_() const { + if (this->pairing_code_display_supported_) { + return LOG_STR_LITERAL("dynamic"); + } + if (this->static_pairing_code_ != nullptr) { + return LOG_STR_LITERAL("static"); + } + return LOG_STR_LITERAL("none"); } const char *SendspinHub::get_product_name_() const { @@ -180,12 +350,26 @@ 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.mac_address = SendspinHub::get_mac_address_into_buffer(mac_buf); config.name = App.get_friendly_name(); 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_; + config.protocol_task_psram_stack = this->task_stack_in_psram_; + config.max_pairing_records = SENDSPIN_RECORD_SLOTS; + + // The dynamic code needs a channel and a format. Only digits, since automations get the bare string and could not + // tell a QR code token apart. + if (this->pairing_code_display_supported_) { + config.pairing_code_out_channels = {sendspin::SendspinPairingCodeChannel::DISPLAY}; + config.pairing_code_formats = {sendspin::SendspinPairingCodeFormat::DIGITS}; + } + + if (this->static_pairing_code_ != nullptr) { + config.static_pairing_code_locations = {"operator"}; + config.static_pairing_code = this->static_pairing_code_; + } return config; } @@ -215,35 +399,77 @@ void SendspinHub::on_release_high_performance() { #endif } +void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks_.call(); } + +void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); } + +// Only digits are offered, and the library refuses an activation in any other format. +void SendspinHub::on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat /*format*/) { + this->display_pairing_code_callbacks_.call(code); +} + +void SendspinHub::on_clear_pairing_code() { this->clear_pairing_code_callbacks_.call(); } + +void SendspinHub::on_pairing_succeeded(const std::string &server_id) { + this->pairing_succeeded_callbacks_.call(server_id); +} + +void SendspinHub::on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) { + this->pairing_failed_callbacks_.call(server_id, pair_abort_reason_to_string(reason)); +} + // --- SendspinNetworkProvider override --- -// THREAD CONTEXT: Main loop (polled by client_->loop()) +// THREAD CONTEXT: Main loop (polled by start() and client_->loop()) bool SendspinHub::is_network_ready() { return network::is_connected(); } // --- SendspinPersistenceProvider overrides --- +// THREAD CONTEXT: Main loop (the library makes every provider call there, including from start() and stop()) -// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) -bool SendspinHub::save_last_server_hash(uint32_t hash) { - LastPlayedServerPref pref{.server_id_hash = hash}; - bool ok = this->last_played_server_pref_.save(&pref); - if (ok) { - ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash); - } else { - ESP_LOGW(TAG, "Failed to persist last played server hash"); +std::pair SendspinHub::pref_for_key_(const std::string &key) { + namespace keys = sendspin::persistence_keys; + if (auto slot = parse_record_slot(key); slot.has_value()) { + return {&this->record_slot_prefs_[*slot], keys::RECORD_SLOT_SIZE}; } - return ok; + if (key == keys::RECORD_ORDER) { + return {&this->record_order_pref_, SENDSPIN_RECORD_SLOTS}; + } + if (key == keys::KEYPAIR) { + return {&this->keypair_pref_, keys::KEYPAIR_SIZE}; + } + if (key == keys::PAIRING_PSK) { + return {&this->pairing_psk_pref_, keys::PAIRING_PSK_SIZE}; + } + if (key == keys::LAST_PLAYED) { + return {&this->last_played_pref_, keys::LAST_PLAYED_SIZE}; + } + if (key == keys::OUTPUT_DELAY) { + return {&this->output_delay_pref_, keys::OUTPUT_DELAY_SIZE}; + } + return {nullptr, 0}; } -// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events) -std::optional SendspinHub::load_last_server_hash() { - LastPlayedServerPref pref{}; - if (this->last_played_server_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash); - return pref.server_id_hash; +std::optional> SendspinHub::load_blob(const std::string &key) { + auto [pref, size] = this->pref_for_key_(key); + if (pref == nullptr) { + ESP_LOGW(TAG, "load_blob: unknown key \"%s\"", key.c_str()); + return std::nullopt; } - return std::nullopt; + return read_blob(*pref, size); } +bool SendspinHub::save_blob(const std::string &key, const uint8_t *data, size_t len) { + auto [pref, size] = this->pref_for_key_(key); + if (pref == nullptr) { + ESP_LOGW(TAG, "save_blob: unknown key \"%s\"", key.c_str()); + return false; + } + return write_blob(*pref, key.c_str(), size, data, len); +} + +// Writes every component's queued preferences, so a failure may belong to another component. +bool SendspinHub::commit() { return global_preferences->sync(); } + // --- Sendspin role specific methods/overrides --- #ifdef USE_SENDSPIN_ARTWORK @@ -284,6 +510,9 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman } } +// THREAD CONTEXT: Main loop (invoked from the sendspin.switch action) +void SendspinHub::switch_client() { 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); @@ -325,31 +554,8 @@ sendspin::PlayerRole *SendspinHub::get_player_role() { } return nullptr; } - -// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) -bool SendspinHub::save_static_delay(uint16_t delay_ms) { - StaticDelayPref pref{.delay_ms = delay_ms}; - bool ok = this->static_delay_pref_.save(&pref); - if (ok) { - ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms); - } else { - ESP_LOGW(TAG, "Failed to persist static delay"); - } - return ok; -} - -// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override) -std::optional SendspinHub::load_static_delay() { - StaticDelayPref pref{}; - if (this->static_delay_pref_.load(&pref)) { - ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms); - return pref.delay_ms; - } - return std::nullopt; -} - #endif } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif // USE_ESP_IDF diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index b00fdc436e..cd8d0d0afa 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -2,12 +2,13 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#ifdef USE_ESP_IDF #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "esphome/core/version.h" #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE @@ -31,9 +32,15 @@ #include #endif +#include +#include +#include #include #include #include +#include +#include +#include namespace esphome::sendspin_ { @@ -44,22 +51,21 @@ namespace esphome::sendspin_ { /// one step later than hub so they can assume hub's setup() has already completed. namespace sendspin_priority { // AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active -// interface's MAC for client_id. +// interface's MAC address for the device info. inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI; inline constexpr float CHILD = HUB - 1.0f; } // namespace sendspin_priority -/// @brief Persistent storage structure for last played server hash. -struct LastPlayedServerPref { - uint32_t server_id_hash; -}; +// PREFERENCE KEYS: each library persistence key has its own preference, hashed from "sendspin_" + name. Renaming +// one erases it. -#ifdef USE_SENDSPIN_PLAYER -/// @brief Persistent storage structure for player static delay. -struct StaticDelayPref { - uint16_t delay_ms; +/// Pairing-record slots, handed to the client as max_pairing_records. +inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS; + +/// Feature flags reported to API clients in DeviceCapabilitiesResponse. +enum SendspinFeature : uint32_t { + SENDSPIN_FEATURE_PAIRING_TOKEN = 1 << 0, }; -#endif /// @brief Thin adapter over sendspin::SendspinClient. /// @@ -90,9 +96,12 @@ class SendspinHub final : public Component, public sendspin::SendspinNetworkProvider, public sendspin::SendspinPersistenceProvider { public: + SendspinHub(); + float get_setup_priority() const override { return sendspin_priority::HUB; } void setup() override; void loop() override; + void on_shutdown() override; void dump_config() override; /// @brief Connects the underlying client to the given Sendspin server. @@ -113,14 +122,22 @@ class SendspinHub final : public Component, /// - `USER_REQUEST`: user explicitly requested disconnect. void disconnect_from_server(sendspin::SendspinGoodbyeReason reason); - /// @brief Updates the client's reported playback state on the server. + /// @brief Leaves the current group so another source can use the speaker. + /// + /// Sends `client/leave` while the group is playing. A stopped group is left alone, so the device stays grouped. + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void leave_group(); + + /// @brief Confirms a pairing attempt on the device. With no attempt waiting, it opens the pairing window for the + /// next one. /// /// 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. - /// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source. - void update_state(sendspin::SendspinClientState state); + void confirm_pairing_window(); + + /// @brief Closes an open pairing window, so a waiting pairing attempt is not confirmed. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void cancel_pairing_window(); // --- Configuration setters (called from codegen) --- @@ -128,19 +145,59 @@ class SendspinHub final : public Component, this->group_update_callbacks_.add(std::forward(callback)); } + template void add_on_open_pairing_window_callback(F &&callback) { + this->open_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_close_pairing_window_callback(F &&callback) { + this->close_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_display_pairing_code_callback(F &&callback) { + this->display_pairing_code_callbacks_.add(std::forward(callback)); + } + + template void add_on_clear_pairing_code_callback(F &&callback) { + this->clear_pairing_code_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_succeeded_callback(F &&callback) { + this->pairing_succeeded_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_failed_callback(F &&callback) { + this->pairing_failed_callbacks_.add(std::forward(callback)); + } + 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. + /// fire from inside that call. The client stays stopped until this has been called once, by codegen or by the + /// enabled switch. Must be called from the main loop thread. void set_enabled(bool enabled); + /// Turns unpaired (Sentinel) access on or off. The client waits for this call, by codegen or by the unpaired + /// access switch, before its first start. May be called before setup(). Main loop only. + void set_unpaired_access_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 Returns whether the client has been turned off with set_enabled(). + bool is_disabled() const { return this->enabled_.has_value() && !this->enabled_.value(); } + + /// @brief Returns the SendspinFeature flags reported to API clients. + uint32_t get_feature_flags() const { return SENDSPIN_FEATURE_PAIRING_TOKEN; } + + /// @brief Returns the pairing token a Sendspin server uses to pair with this device. + /// + /// The token is a long-lived secret, so this component never logs it. Returns std::nullopt until the client has + /// started once; it stays available after the client stops. Main loop only. + std::optional get_pairing_token() const; + /// @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 @@ -154,6 +211,13 @@ class SendspinHub final : public Component, void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } #endif + /// The static pairing code, used on every boot. + void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; } + + /// Set when on_display_pairing_code or a pairing_code text sensor is configured, so the device offers the dynamic + /// pairing code. + void set_pairing_code_display_supported(bool supported) { this->pairing_code_display_supported_ = supported; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -181,6 +245,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)); } @@ -216,12 +283,18 @@ class SendspinHub final : public Component, /// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info. sendspin::SendspinClientConfig build_client_config_(); + /// The preference a library key is stored in and its blob size, or {nullptr, 0} for a key with + /// no storage here. + std::pair pref_for_key_(const std::string &key); + /// @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. + const char *pairing_code_method_() const; + + /// @brief Writes the active network interface's MAC, in lowercase, 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); + static const char *get_mac_address_into_buffer(std::span buf); #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE /// @brief Keeps the `_sendspin` mDNS service advertised while the client is running. @@ -235,12 +308,26 @@ class SendspinHub final : public Component, void on_release_high_performance() override; + void on_open_pairing_window() override; + + void on_close_pairing_window() override; + + void on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat format) override; + + void on_clear_pairing_code() override; + + void on_pairing_succeeded(const std::string &server_id) override; + + void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override; + // --- SendspinNetworkProvider override --- bool is_network_ready() override; // --- SendspinPersistenceProvider overrides --- - bool save_last_server_hash(uint32_t hash) override; - std::optional load_last_server_hash() override; + // The library calls commit() after writing pairing secrets, so they reach flash right away. + std::optional> load_blob(const std::string &key) override; + bool save_blob(const std::string &key, const uint8_t *data, size_t len) override; + bool commit() override; // --- Sendspin role specific methods/overrides/member variables --- @@ -288,26 +375,41 @@ class SendspinHub final : public Component, #ifdef USE_SENDSPIN_PLAYER sendspin::PlayerRoleListener *player_listener_{nullptr}; sendspin::PlayerRoleConfig player_config_{}; - - // Part of SendspinPersistenceProvider overrides - ESPPreferenceObject static_delay_pref_; - std::optional load_static_delay() override; - bool save_static_delay(uint16_t delay_ms) override; #endif // --- Core member variables --- - ESPPreferenceObject last_played_server_pref_; + // Built once in setup(): make_preference() allocates a backend that is never freed. + ESPPreferenceObject keypair_pref_; + ESPPreferenceObject pairing_psk_pref_; + ESPPreferenceObject last_played_pref_; + ESPPreferenceObject output_delay_pref_; + std::array record_slot_prefs_; + ESPPreferenceObject record_order_pref_; std::unique_ptr client_; // Callback fan-out to child components CallbackManager group_update_callbacks_{}; - bool task_stack_in_psram_{false}; + // Lazy: each pairing callback is fed by an optional YAML surface. + LazyCallbackManager open_pairing_window_callbacks_{}; + LazyCallbackManager close_pairing_window_callbacks_{}; + LazyCallbackManager display_pairing_code_callbacks_{}; + LazyCallbackManager clear_pairing_code_callbacks_{}; + LazyCallbackManager pairing_succeeded_callbacks_{}; + LazyCallbackManager pairing_failed_callbacks_{}; - // Requested client state, applied from loop(). Empty until the switch restores its state. + const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured + bool pairing_code_display_supported_{false}; + 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(). The client does not start until both are set. std::optional enabled_; + std::optional unpaired_access_; // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. @@ -317,7 +419,6 @@ class SendspinHub final : public Component, #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE mdns::MDNSComponent *mdns_{nullptr}; - bool mdns_advertised_{false}; // Last state requested from mdns #endif }; @@ -342,6 +443,8 @@ class SendspinPollingChild : public PollingComponent, public Parented @@ -8,7 +8,7 @@ namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.sensor"; +ESPHOME_LOG_TAG(TAG, "sendspin.sensor"); // --- SendspinTrackProgressSensor --- diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index 5b29fff55f..77bb835598 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/sensor/sensor.h" diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 63f5f7ad28..63d30d0c03 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,29 +1,102 @@ +import logging + import esphome.codegen as cg from esphome.components import switch +from esphome.components.const import CONF_ENABLED +from esphome.components.switch import DOMAIN as SWITCH_DOMAIN import esphome.config_validation as cv -from esphome.const import ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_PLATFORM, CONF_TYPE, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + CONF_STATIC_PAIRING_CODE, + CONF_UNPAIRED_ACCESS, + DOMAIN, + SendspinHub, + _has_pairing_method, + request_switch, + sendspin_ns, +) + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] -SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) +SendspinEnabledSwitch = sendspin_ns.class_( + "SendspinEnabledSwitch", switch.Switch, cg.Component +) +SendspinUnpairedAccessSwitch = sendspin_ns.class_( + "SendspinUnpairedAccessSwitch", switch.Switch, cg.Component +) + + +def _switch_schema(class_: cg.MockObjClass) -> cv.Schema: + return ( + switch.switch_schema( + class_, + 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) + ) + + +def _request_switch(config: ConfigType) -> ConfigType: + """Tell the hub to wait for this switch before the client's first start.""" + request_switch(config[CONF_TYPE]) + return config + CONFIG_SCHEMA = cv.All( - switch.switch_schema( - SendspinSwitch, - block_inverted=True, - default_restore_mode="RESTORE_DEFAULT_ON", - entity_category=ENTITY_CATEGORY_CONFIG, - ) - .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) - .extend(cv.COMPONENT_SCHEMA), + cv.typed_schema( + { + CONF_ENABLED: _switch_schema(SendspinEnabledSwitch), + CONF_UNPAIRED_ACCESS: _switch_schema(SendspinUnpairedAccessSwitch), + }, + key=CONF_TYPE, + ), cv.only_on_esp32, + _request_switch, ) +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + switch_type = config[CONF_TYPE] + same_type = [ + conf + for conf in full_config.get(SWITCH_DOMAIN, []) + if conf.get(CONF_PLATFORM) == DOMAIN and conf.get(CONF_TYPE) == switch_type + ] + # Two switches of one type would each drive the same hub setting. + if len(same_type) > 1: + raise cv.Invalid(f"Only one sendspin '{switch_type}' switch is allowed") + if switch_type != CONF_UNPAIRED_ACCESS: + return config + hub_config = full_config.get(DOMAIN, {}) + if CONF_UNPAIRED_ACCESS in hub_config: + raise cv.Invalid( + f"'{DOMAIN}: {CONF_UNPAIRED_ACCESS}' cannot be set together with an " + f"{CONF_UNPAIRED_ACCESS} switch; set the switch's restore_mode instead" + ) + if not _has_pairing_method(hub_config): + _LOGGER.warning( + "The %s switch has no pairing method (%s or a dynamic pairing code), so " + "while it is off no new server can pair with this device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index 0bf029d4c7..51c151980a 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -1,26 +1,49 @@ #include "sendspin_switch.h" -#ifdef USE_ESP32 +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/core/log.h" +#include "esphome/core/preferences.h" namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.switch"; +ESPHOME_LOG_TAG(TAG, "sendspin.switch"); -void SendspinSwitch::setup() { +// --- SendspinEnabledSwitch --- + +void SendspinEnabledSwitch::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); } +void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); } // THREAD CONTEXT: Main loop -void SendspinSwitch::write_state(bool state) { +void SendspinEnabledSwitch::write_state(bool state) { this->parent_->set_enabled(state); this->publish_state(state); } +// --- SendspinUnpairedAccessSwitch --- + +void SendspinUnpairedAccessSwitch::setup() { + // The hub holds the client's first start for this, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinUnpairedAccessSwitch::dump_config() { LOG_SWITCH("", "Sendspin Unpaired Access Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinUnpairedAccessSwitch::write_state(bool state) { + const bool turning_off = !state && this->has_state() && this->state; + this->parent_->set_unpaired_access_enabled(state); + this->publish_state(state); + // Write the change to flash now: losing it in a power cut would reopen unpaired access. + if (turning_off) { + global_preferences->sync(); + } +} + } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index 253d952b22..4b04c95857 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP32 +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/switch/switch.h" @@ -10,7 +10,19 @@ 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 { +class SendspinEnabledSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +/// @brief Switch that turns unpaired (Sentinel) access on and off through the hub (see +/// SendspinHub::set_unpaired_access_enabled()). The library does not persist the setting, so the switch's restore +/// mode does. +class SendspinUnpairedAccessSwitch final : public switch_::Switch, public SendspinChild { public: void setup() override; void dump_config() override; @@ -21,4 +33,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild { } // namespace esphome::sendspin_ -#endif // USE_ESP32 +#endif diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py index 87f6c9b936..24aa2d5734 100644 --- a/esphome/components/sendspin/text_sensor/__init__.py +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -4,16 +4,29 @@ import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_TYPE from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + SendspinHub, + request_metadata_support, + request_pairing_code_display_support, + sendspin_ns, +) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] +CONF_PAIRING_CODE = "pairing_code" + SendspinTextSensor = sendspin_ns.class_( "SendspinTextSensor", text_sensor.TextSensor, cg.Component, ) +SendspinPairingCodeTextSensor = sendspin_ns.class_( + "SendspinPairingCodeTextSensor", + text_sensor.TextSensor, + cg.Component, +) SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) SENDSPIN_TEXT_METADATA_TYPES = { @@ -26,18 +39,29 @@ SENDSPIN_TEXT_METADATA_TYPES = { def _request_roles(config: ConfigType) -> ConfigType: """Request the necessary Sendspin roles for the text sensor.""" - request_metadata_support() + if config[CONF_TYPE] == CONF_PAIRING_CODE: + # A display surface for the dynamic code, so advertise dynamic_pairing_code. + request_pairing_code_display_support() + else: + request_metadata_support() return config +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) +_METADATA_SCHEMA = text_sensor.text_sensor_schema(SendspinTextSensor).extend( + _HUB_ID_SCHEMA +) + CONFIG_SCHEMA = cv.All( - text_sensor.text_sensor_schema().extend( + cv.typed_schema( { - cv.GenerateID(): cv.declare_id(SendspinTextSensor), - cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES), - } + **dict.fromkeys(SENDSPIN_TEXT_METADATA_TYPES, _METADATA_SCHEMA), + CONF_PAIRING_CODE: text_sensor.text_sensor_schema( + SendspinPairingCodeTextSensor + ).extend(_HUB_ID_SCHEMA), + }, + key=CONF_TYPE, ), cv.only_on_esp32, _request_roles, @@ -50,4 +74,7 @@ async def to_code(config: ConfigType) -> None: await cg.register_parented(var, config[CONF_SENDSPIN_ID]) await text_sensor.register_text_sensor(var, config) - cg.add(var.set_metadata_type(config[CONF_TYPE])) + if ( + metadata_type := SENDSPIN_TEXT_METADATA_TYPES.get(config[CONF_TYPE]) + ) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 554e01cf88..629396a681 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -1,15 +1,18 @@ #include "sendspin_text_sensor.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR) +#ifdef USE_SENDSPIN_METADATA #include +#endif #include namespace esphome::sendspin_ { -static const char *const TAG = "sendspin.text_sensor"; +ESPHOME_LOG_TAG(TAG, "sendspin.text_sensor"); +#ifdef USE_SENDSPIN_METADATA void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } // A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to @@ -50,6 +53,16 @@ void SendspinTextSensor::publish_if_changed_(const char *value) { this->publish_state(value); } } +#endif // USE_SENDSPIN_METADATA + +void SendspinPairingCodeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Pairing Code", this); } + +// THREAD CONTEXT: Main loop. The registered callbacks also fire on the main loop (SendspinHub dispatches them from +// its loop()). +void SendspinPairingCodeTextSensor::setup() { + this->parent_->add_on_display_pairing_code_callback([this](const std::string &code) { this->publish_state(code); }); + this->parent_->add_on_clear_pairing_code_callback([this]() { this->publish_state(""); }); +} } // namespace esphome::sendspin_ diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index d38f360d94..cc25ab93c7 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -2,15 +2,19 @@ #include "esphome/core/defines.h" -#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) +#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/text_sensor/text_sensor.h" +#ifdef USE_SENDSPIN_METADATA #include +#endif namespace esphome::sendspin_ { +#ifdef USE_SENDSPIN_METADATA + enum class SendspinTextMetadataTypes { TITLE, ARTIST, @@ -31,6 +35,13 @@ class SendspinTextSensor final : public SendspinChild, public text_sensor::TextS SendspinTextMetadataTypes metadata_type_; }; +#endif // USE_SENDSPIN_METADATA + +class SendspinPairingCodeTextSensor final : public SendspinChild, public text_sensor::TextSensor { + public: + void dump_config() override; + void setup() override; +}; } // namespace esphome::sendspin_ #endif diff --git a/esphome/components/senseair/__init__.py b/esphome/components/senseair/__init__.py index e69de29bb2..5973c85b6a 100644 --- a/esphome/components/senseair/__init__.py +++ b/esphome/components/senseair/__init__.py @@ -0,0 +1 @@ +DOMAIN = "senseair" diff --git a/esphome/components/senseair/senseair.cpp b/esphome/components/senseair/senseair.cpp index f5017cff75..858510f427 100644 --- a/esphome/components/senseair/senseair.cpp +++ b/esphome/components/senseair/senseair.cpp @@ -4,7 +4,7 @@ namespace esphome::senseair { -static const char *const TAG = "senseair"; +ESPHOME_LOG_TAG(TAG, "senseair"); static const uint8_t SENSEAIR_REQUEST_LENGTH = 8; static const uint8_t SENSEAIR_PPM_STATUS_RESPONSE_LENGTH = 13; static const uint8_t SENSEAIR_ABC_PERIOD_RESPONSE_LENGTH = 7; 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/__init__.py b/esphome/components/sensirion_common/__init__.py index 5d9424d1e7..e1fc793d6d 100644 --- a/esphome/components/sensirion_common/__init__.py +++ b/esphome/components/sensirion_common/__init__.py @@ -2,6 +2,7 @@ import esphome.codegen as cg from esphome.components import i2c CODEOWNERS = ["@martgras"] +DOMAIN = "sensirion_common" sensirion_common_ns = cg.esphome_ns.namespace("sensirion_common") diff --git a/esphome/components/sensirion_common/i2c_sensirion.cpp b/esphome/components/sensirion_common/i2c_sensirion.cpp index f6ff4711d4..19172e68d2 100644 --- a/esphome/components/sensirion_common/i2c_sensirion.cpp +++ b/esphome/components/sensirion_common/i2c_sensirion.cpp @@ -6,7 +6,7 @@ namespace esphome::sensirion_common { -static const char *const TAG = "sensirion_i2c"; +ESPHOME_LOG_TAG(TAG, "sensirion_i2c"); // To avoid memory allocations for small writes a stack buffer is used static const size_t BUFFER_STACK_SIZE = 16; @@ -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 62d78a32dd..8f345e3e66 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -1,5 +1,6 @@ import logging import math +from typing import Any from esphome import automation import esphome.codegen as cg @@ -43,6 +44,7 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TO, CONF_TRIGGER_ID, + CONF_TYPE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WEB_SERVER, @@ -111,9 +113,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,9 +125,11 @@ 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"] +DOMAIN = "sensor" DEVICE_CLASSES = [ DEVICE_CLASS_ABSOLUTE_HUMIDITY, @@ -219,6 +224,13 @@ def validate_send_first_at(value): FILTER_REGISTRY = Registry() validate_filters = cv.validate_registry("filter", FILTER_REGISTRY) +# Default for streamed readings (radar sensors): hold the last value for a second so a dropped +# frame doesn't read as absence, then rate limit. Codegen turns the pair into one TimeoutThrottleFilter. +TIMEOUT_THROTTLE_FILTERS = [ + {"timeout": {"timeout": cv.TimePeriod(milliseconds=1000), "value": "last"}}, + {"throttle_with_priority": cv.TimePeriod(milliseconds=1000)}, +] + def validate_datapoint(value): if isinstance(value, dict): @@ -290,6 +302,7 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_( TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) +TimeoutThrottleFilter = sensor_ns.class_("TimeoutThrottleFilter", TimeoutFilterLast) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter) HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) @@ -659,7 +672,7 @@ async def delta_filter_to_code(config, filter_id): @FILTER_REGISTRY.register("or", OrFilter, validate_filters) async def or_filter_to_code(config, filter_id): filters = await build_filters(config) - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), filters) + return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(filters)), *filters) @FILTER_REGISTRY.register( @@ -680,6 +693,12 @@ THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value( ) +def _is_nan_only(values: Any) -> bool: + if not isinstance(values, list): + values = [values] + return bool(values) and all(isinstance(v, float) and math.isnan(v) for v in values) + + @FILTER_REGISTRY.register( "throttle_with_priority", ThrottleWithPriorityFilter, @@ -693,7 +712,7 @@ async def throttle_with_priority_filter_to_code(config, filter_id): # omits `value:`) to avoid the TemplatableFn array + NaN lambda the # generic ValueListFilter path requires. Behavior is identical: NaN sensor # readings always bypass the throttle. - if values and all(isinstance(v, float) and math.isnan(v) for v in values): + if _is_nan_only(values): filter_id = filter_id.copy() filter_id.type = ThrottleWithPriorityNanFilter return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) @@ -787,6 +806,9 @@ def validate_calibrate_linear(config): return config +LINEAR_SEGMENT = cg.std_ns.class_("array").template(cg.float_, 3) + + @FILTER_REGISTRY.register( "calibrate_linear", CalibrateLinearFilter, @@ -813,9 +835,11 @@ async def calibrate_linear_filter_to_code(config, filter_id): linear_functions = [[k, b, float("NaN")]] elif config[CONF_METHOD] == "exact": linear_functions = map_linear(x, y) - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(linear_functions)), linear_functions + # The last segment always has a NaN boundary, so the filter needs no count. + table = cg.shared_progmem_array( + "sensor_calibrate_linear", LINEAR_SEGMENT, linear_functions ) + return cg.new_Pvariable(filter_id, table) CONF_DEGREE = "degree" @@ -853,7 +877,11 @@ async def calibrate_polynomial_filter_to_code(config, filter_id): # Column vector b = [[v] for v in y] res = [v[0] for v in _lstsq(a, b)] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(res)), res) + # The first entry holds the coefficient count, so the filter needs no count field. + table = cg.shared_progmem_array( + "sensor_calibrate_polynomial", cg.float_, [float(len(res)), *res] + ) + return cg.new_Pvariable(filter_id, table) def validate_clamp(config): @@ -938,6 +966,43 @@ async def build_filters(config): return await cg.build_registry_list(FILTER_REGISTRY, config) +def _timeout_throttle_period( + first: ConfigType, second: ConfigType +) -> cv.TimePeriod | None: + """Period of a `timeout` (value `last`) directly followed by a NaN only `throttle_with_priority`.""" + timeout = first.get("timeout") + throttle = second.get("throttle_with_priority") + if not isinstance(timeout, dict) or not isinstance(throttle, dict): + return None + if timeout[CONF_VALUE] != "last" or timeout[CONF_TIMEOUT] != throttle[CONF_TIMEOUT]: + return None + if not _is_nan_only(throttle[CONF_VALUE]): + return None + return timeout[CONF_TIMEOUT] + + +async def _build_chain_filters(config: list[ConfigType]) -> list: + """Like build_filters, but merges the radar `timeout` + `throttle_with_priority` pair into one filter.""" + filters = [] + i = 0 + while i < len(config): + if ( + i + 1 < len(config) + and (period := _timeout_throttle_period(config[i], config[i + 1])) + is not None + ): + filter_id = config[i][CONF_TYPE_ID].copy() + filter_id.type = TimeoutThrottleFilter + var = cg.new_Pvariable(filter_id, period) + await cg.register_component(var, {}) + filters.append(var) + i += 2 + continue + filters.append(await cg.build_registry_entry(FILTER_REGISTRY, config[i])) + i += 1 + return filters + + _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( CONF_ON_VALUE, "add_on_state_callback", [(float, "x")] @@ -976,8 +1041,9 @@ async def setup_sensor_core_(var, config): cg.add(var.set_force_update(True)) if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_SENSOR_FILTER") - filters = await build_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + filters = await _build_chain_filters(config[CONF_FILTERS]) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_sensor_automations, var, config) @@ -1013,6 +1079,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/filter.cpp b/esphome/components/sensor/filter.cpp index dbd6f4d34b..ed48ddebdb 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -11,7 +11,18 @@ namespace esphome::sensor { -static const char *const TAG = "sensor.filter"; +ESPHOME_LOG_TAG(TAG, "sensor.filter"); + +/// Shared pass check for throttle_with_priority (NaN only): passes and stamps `last_input` when the +/// period has elapsed, on the first value, or for NaN. +static bool throttle_nan_passes(uint32_t &last_input, uint32_t period, float value) { + const uint32_t now = App.get_loop_component_start_time(); + if (last_input == 0 || now - last_input >= period || std::isnan(value)) { + last_input = now; + return true; + } + return false; +} // Filter void Filter::input(float value) { @@ -266,11 +277,8 @@ optional throttle_with_priority_new_value(Sensor *parent, float value, co ThrottleWithPriorityNanFilter::ThrottleWithPriorityNanFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {} optional ThrottleWithPriorityNanFilter::new_value(float value) { - const uint32_t now = App.get_loop_component_start_time(); - if (this->last_input_ == 0 || now - this->last_input_ >= this->min_time_between_inputs_ || std::isnan(value)) { - this->last_input_ = now; + if (throttle_nan_passes(this->last_input_, this->min_time_between_inputs_, value)) return value; - } return {}; } @@ -355,6 +363,23 @@ optional TimeoutFilterConfigured::new_value(float value) { return value; } +// TimeoutThrottleFilter +optional TimeoutThrottleFilter::new_value(float value) { + TimeoutFilterLast::new_value(value); + if (throttle_nan_passes(this->last_input_, this->time_period_, value)) + return value; + return {}; +} + +void TimeoutThrottleFilter::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->timeout_start_time_ >= this->time_period_) { + if (throttle_nan_passes(this->last_input_, this->time_period_, this->pending_value_)) + this->output(this->pending_value_); + this->disable_loop(); + } +} + // DebounceFilter optional DebounceFilter::new_value(float value) { App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); }); @@ -390,19 +415,21 @@ void HeartbeatFilter::initialize(Sensor *parent, Filter *next) { }); } -optional calibrate_linear_compute(const std::array *functions, size_t count, float value) { - for (size_t i = 0; i < count; i++) { - if (!std::isfinite(functions[i][2]) || value < functions[i][2]) - return (value * functions[i][0]) + functions[i][1]; +// The calibration tables hold only floats, so aligned 32-bit loads from PROGMEM are safe on ESP8266. +optional CalibrateLinearFilter::new_value(float value) { + // Always returns: codegen ends the table with a segment whose boundary is NaN. + for (const auto *function = this->functions_;; function++) { + if (!std::isfinite((*function)[2]) || value < (*function)[2]) + return (value * (*function)[0]) + (*function)[1]; } - return NAN; } -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value) { +optional CalibratePolynomialFilter::new_value(float value) { float res = 0.0f; float x = 1.0f; - for (size_t i = 0; i < count; i++) { - res += x * coefficients[i]; + const auto count = static_cast(this->coefficients_[0]); + for (size_t i = 1; i <= count; i++) { + res += x * this->coefficients_[i]; x *= value; } return res; @@ -450,7 +477,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/filter.h b/esphome/components/sensor/filter.h index bc086e3805..9b5ef49308 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -479,6 +479,19 @@ class TimeoutFilterConfigured : public TimeoutFilterBase { // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead }; +/// Codegen use only: `timeout` (value `last`) followed by `throttle_with_priority` (NaN only) with the +/// same period, as one object. Output matches the two filter chain exactly. +class TimeoutThrottleFilter : public TimeoutFilterLast { + public: + explicit TimeoutThrottleFilter(uint32_t time_period) : TimeoutFilterLast(time_period) {} + + optional new_value(float value) override; + void loop() override; + + protected: + uint32_t last_input_{0}; // When the throttle stage last let a value through +}; + class DebounceFilter : public Filter { public: explicit DebounceFilter(uint32_t time_period); @@ -532,6 +545,9 @@ optional or_filter_new_value(Filter **filters, size_t count, float value, template class OrFilter : public Filter { public: explicit OrFilter(std::initializer_list filters) { init_array_from(this->filters_, filters); } + // Codegen passes each filter as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Fs) == N) explicit OrFilter(Fs *...filters) : filters_{filters...} {} void initialize(Sensor *parent, Filter *next) override { Filter::initialize(parent, next); @@ -563,38 +579,24 @@ template class OrFilter : public Filter { bool has_value_{false}; }; -/// Non-template helper for linear calibration (implementation in filter.cpp) -optional calibrate_linear_compute(const std::array *functions, size_t count, float value); - -/// N is set by code generation to match the exact number of calibration segments. -template class CalibrateLinearFilter : public Filter { +/// Piecewise linear calibration; the segments are a PROGMEM table shared by filters with the same data. +class CalibrateLinearFilter : public Filter { public: - explicit CalibrateLinearFilter(std::initializer_list> linear_functions) { - init_array_from(this->linear_functions_, linear_functions); - } - optional new_value(float value) override { - return calibrate_linear_compute(this->linear_functions_.data(), N, value); - } + explicit CalibrateLinearFilter(const std::array *functions) : functions_(functions) {} + optional new_value(float value) override; protected: - std::array, N> linear_functions_{}; + const std::array *functions_; // Last segment has a NaN boundary, which ends the table }; -/// Non-template helper for polynomial calibration (implementation in filter.cpp) -optional calibrate_polynomial_compute(const float *coefficients, size_t count, float value); - -/// N is set by code generation to match the exact number of polynomial coefficients. -template class CalibratePolynomialFilter : public Filter { +/// Polynomial calibration; the coefficients are a PROGMEM table shared by filters with the same data. +class CalibratePolynomialFilter : public Filter { public: - explicit CalibratePolynomialFilter(std::initializer_list coefficients) { - init_array_from(this->coefficients_, coefficients); - } - optional new_value(float value) override { - return calibrate_polynomial_compute(this->coefficients_.data(), N, value); - } + explicit CalibratePolynomialFilter(const float *coefficients) : coefficients_(coefficients) {} + optional new_value(float value) override; protected: - std::array coefficients_{}; + const float *coefficients_; // [count, c0, c1, ...] }; class ClampFilter : public Filter { diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index bee5d7c6d3..df73e2c481 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::sensor { -static const char *const TAG = "sensor"; +ESPHOME_LOG_TAG(TAG, "sensor"); // Function implementation of LOG_SENSOR macro to reduce code size void log_sensor(const char *tag, const char *prefix, const char *type, Sensor *obj) { diff --git a/esphome/components/serial_proxy/__init__.py b/esphome/components/serial_proxy/__init__.py index b6e780fabd..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"] @@ -38,6 +40,7 @@ SERIAL_PROXY_PORT_TYPES = { "TTL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_TTL, "RS232": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS232, "RS485": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_RS485, + "USB_SERIAL": SerialProxyPortType.SERIAL_PROXY_PORT_TYPE_USB_SERIAL, } CONF_DTR_PIN = "dtr_pin" @@ -73,6 +76,18 @@ CONFIG_SCHEMA = ( ) +def _final_validate(config: ConfigType) -> ConfigType: + is_usb = is_usb_uart_channel(config[CONF_UART_ID], fv.full_config.get()) + if config[CONF_PORT_TYPE] == "USB_SERIAL" and not is_usb: + raise cv.Invalid( + f"{CONF_PORT_TYPE} USB_SERIAL requires {CONF_UART_ID} to be a usb_uart channel" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_serial_proxy_count_define() -> None: """Emit the SERIAL_PROXY_COUNT define once with the final instance count.""" @@ -88,6 +103,12 @@ async def to_code(config: ConfigType) -> None: cg.add(cg.App.register_serial_proxy(var)) cg.add(var.set_name(config[CONF_NAME])) cg.add(var.set_port_type(config[CONF_PORT_TYPE])) + # port_type names the electrical interface (a USB RS485 adapter is RS485), so every + # usb_uart channel reports USB identity whatever port type it declares + if is_usb_uart_channel(config[CONF_UART_ID], CORE.config): + channel = await cg.get_variable(config[CONF_UART_ID]) + cg.add(var.set_usb_channel(channel)) + cg.add_define("USE_SERIAL_PROXY_USB_IDENTITY") cg.add_define("USE_SERIAL_PROXY") # Track instance count for the FINAL priority define diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 129745c1c9..9623eb21b3 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,9 +14,16 @@ #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"; +ESPHOME_LOG_TAG(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 @@ -30,6 +39,12 @@ void SerialProxy::setup() { // instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data this->outgoing_msg_.instance = this->instance_index_; #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + if (this->usb_channel_ != nullptr) { + this->usb_channel_->get_parent()->add_on_connection_callback( + [this](bool connected) { this->on_usb_connection_changed_(connected); }); + } +#endif #ifdef USE_SERIAL_PROXY_TAP // A tap sets itself up before this runs (its setup priority is higher), so it may // already be waiting on the port -- a boot-time handshake with the device, say. Leaving @@ -156,9 +171,10 @@ void SerialProxy::dump_config() { " RTS Pin: %s\n" " DTR Pin: %s", this->instance_index_, this->name_ != nullptr ? this->name_ : "", - this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485") - : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232") - : LOG_STR_LITERAL("TTL"), + this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS485 ? LOG_STR_LITERAL("RS485") + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_RS232 ? LOG_STR_LITERAL("RS232") + : this->port_type_ == api::enums::SERIAL_PROXY_PORT_TYPE_USB_SERIAL ? LOG_STR_LITERAL("USB_SERIAL") + : LOG_STR_LITERAL("TTL"), this->rts_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured"), this->dtr_pin_ != nullptr ? LOG_STR_LITERAL("configured") : LOG_STR_LITERAL("not configured")); } @@ -261,10 +277,16 @@ SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_conn 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 entering_protocol_mode = + this->mode_ != api::enums::SERIAL_PROXY_MODE_PROTOCOL && mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL; const bool leaving_protocol_mode = this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW; this->mode_ = mode; + if (entering_protocol_mode && this->tap_ != nullptr) { + this->tap_->on_protocol_enabled(); + } + // 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. @@ -293,6 +315,35 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui #endif if (data == nullptr || len == 0) return; + // Whatever the driver cannot buffer stalls the main loop for its wire time. At high baud + // rates that is brief and losing nothing is worth it; at low ones it would trip the + // watchdog, so cap the stall and drop the rest. The cap covers the whole loop pass: + // several writes can arrive in one, and each alone might stay under it. + const size_t free = this->parent_->available_for_write(); + bool trimmed = false; + if (len > free) { + const uint32_t loop_time = App.get_loop_component_start_time(); + if (loop_time != stall_loop_time) { + stall_loop_time = loop_time; + stall_spent_ms = 0; + } + const uint32_t stall_ms = this->wire_time_ms_(len - free); + trimmed = stall_spent_ms + stall_ms > SERIAL_PROXY_MAX_WRITE_STALL_MS; + if (trimmed && !this->trim_warned_) { + ESP_LOGW(TAG, + "TX buffer full on serial proxy [%" PRIu32 "]: dropping %zu of %zu bytes (would stall %" PRIu32 + " ms at %" PRIu32 " baud); raise the UART tx_buffer_size or pace writes", + this->instance_index_, len - free, len, stall_ms, this->parent_->get_baud_rate()); + } + if (trimmed) { + len = free; + } else { + stall_spent_ms += stall_ms; + } + } + this->trim_warned_ = trimmed; + if (len == 0) + return; this->write_array(data, len); #ifdef USE_SERIAL_PROXY_TAP @@ -303,6 +354,12 @@ void SerialProxy::write_from_client(api::APIConnection *api_connection, const ui #endif } +uint32_t SerialProxy::wire_time_ms_(size_t bytes) const { + const uint32_t bits_per_byte = 1 + this->parent_->get_data_bits() + this->parent_->get_stop_bits() + + (this->parent_->get_parity() != uart::UART_CONFIG_PARITY_NONE ? 1 : 0); + return static_cast(bytes) * bits_per_byte * 1000 / std::max(this->parent_->get_baud_rate(), 1); +} + SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) { #ifdef USE_API if (!this->is_subscriber_(api_connection)) { @@ -333,6 +390,68 @@ 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_SERIAL_PROXY_TAP + // Before telling clients, so a tap never acknowledges a frame from the old device after + // a client has been told it is gone. The subscriber and the mode stay: both belong to + // the client's session, and only the client knows whether that session is over. + if (!connected && this->tap_ != nullptr) { + this->tap_->on_device_disconnected(); + } +#endif +#ifdef USE_API + if (api::global_api_server == nullptr) { + return; + } + IdentityScratch scratch; + api::SerialProxyIdentity msg{}; + this->fill_identity_(scratch, msg); + api::global_api_server->send_serial_proxy_identity(msg); +#endif +} +#endif + uint32_t SerialProxy::get_modem_pins() const { return (this->rts_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_RTS) : 0u) | (this->dtr_state_ ? static_cast(SERIAL_PROXY_LINE_STATE_FLAG_DTR) : 0u); diff --git a/esphome/components/serial_proxy/serial_proxy.h b/esphome/components/serial_proxy/serial_proxy.h index e3f4264cfa..2b8f933f88 100644 --- a/esphome/components/serial_proxy/serial_proxy.h +++ b/esphome/components/serial_proxy/serial_proxy.h @@ -20,6 +20,15 @@ #include "esphome/components/api/api_pb2.h" #endif +#ifdef USE_SERIAL_PROXY_USB_IDENTITY +namespace esphome::usb_uart { +class USBUartChannel; +} // namespace esphome::usb_uart +namespace esphome::usb_host { +struct UsbDeviceInfo; +} // namespace esphome::usb_host +#endif + // Forward-declare types needed outside the USE_API guard. namespace esphome::api { class APIConnection; @@ -53,6 +62,11 @@ enum class SerialProxyResult : uint8_t { /// Maximum bytes to read from UART in a single loop iteration inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256; +/// Longest main-loop stall client writes may cause per loop pass, shared by every instance; +/// bytes the UART cannot buffer within it are dropped. Well under the shortest watchdog +/// timeout, since the API hands the proxies up to ten writes in one pass. +inline constexpr uint32_t SERIAL_PROXY_MAX_WRITE_STALL_MS = 1000; + #ifdef USE_SERIAL_PROXY_TAP /// Observes a port's traffic without owning it, and may inject bytes of its own. /// @@ -75,11 +89,20 @@ class SerialProxyTap { /// subscriber holds the port; with one attached, the port mode alone decides. virtual bool tap_needs_port() const = 0; + /// A client turned protocol handling on for this port. The tap has seen none of the + /// traffic since it was last on, so anything it remembers from then is stale. + virtual void on_protocol_enabled() = 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; + + /// The device behind the port went away -- unplugged, or its power was cut. Whatever + /// session the tap had observed ended with it; the device that appears next starts from + /// scratch and may not even be the same one. Only a USB UART can report this. + virtual void on_device_disconnected() = 0; }; #endif @@ -155,6 +178,16 @@ class SerialProxy final : public uart::UARTDevice, public Component { /// Set the DTR GPIO pin (from YAML configuration) void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; } +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// Attach the USB UART channel behind this port (from code generation) + void set_usb_channel(usb_uart::USBUartChannel *channel) { this->usb_channel_ = channel; } +#endif + +#ifdef USE_API + /// Send this port's identity to one client + void send_identity(api::APIConnection *api_connection); +#endif + #ifdef USE_SERIAL_PROXY_TAP /// Attach a traffic observer. At most one, set once at setup time. void set_tap(SerialProxyTap *tap) { this->tap_ = tap; } @@ -204,6 +237,9 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; } #endif + /// Time the wire needs for the given number of bytes at the current framing + uint32_t wire_time_ms_(size_t bytes) const; + #ifdef USE_SERIAL_PROXY_TAP /// Return the port to RAW when a subscriber goes away, so the mode never outlives it void reset_mode_(); @@ -218,9 +254,32 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool tap_observing_() const; #endif +#ifdef USE_API +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + using IdentityScratch = usb_host::UsbDeviceInfo; +#else + struct IdentityScratch {}; +#endif + /// Fill an identity message for this port. The message's strings are views into scratch, + /// so it must outlive the send. + void fill_identity_(IdentityScratch &scratch, api::SerialProxyIdentity &msg) const; +#endif + +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// The USB device behind this port was attached or removed; report the port's new + /// identity to every subscribed API client + void on_usb_connection_changed_(bool connected); +#endif + /// Instance index for identifying this proxy in API messages uint32_t instance_index_{0}; + /// Stall spent by writes in the current loop pass, keyed by the pass's cached start time. + /// Static on purpose: there is one main loop, and writes to different ports that arrive in + /// the same pass all stall it, so the budget is one per device rather than one per port + static uint32_t stall_loop_time; + static uint32_t stall_spent_ms; + /// Subscribed API client (only one allowed at a time) api::APIConnection *api_connection_{nullptr}; @@ -248,9 +307,17 @@ class SerialProxy final : public uart::UARTDevice, public Component { bool rts_state_{false}; bool dtr_state_{false}; + /// Set while writes are being trimmed, so a client streaming into a slow port warns once + bool trim_warned_{false}; + #ifdef USE_SERIAL_PROXY_TAP SerialProxyTap *tap_{nullptr}; #endif + +#ifdef USE_SERIAL_PROXY_USB_IDENTITY + /// The USB UART channel behind this port; nullptr on non-USB ports + usb_uart::USBUartChannel *usb_channel_{nullptr}; +#endif }; } // namespace esphome::serial_proxy diff --git a/esphome/components/servo/__init__.py b/esphome/components/servo/__init__.py index 74acaa3496..44ec294765 100644 --- a/esphome/components/servo/__init__.py +++ b/esphome/components/servo/__init__.py @@ -19,6 +19,7 @@ servo_ns = cg.esphome_ns.namespace("servo") Servo = servo_ns.class_("Servo", cg.Component) CONF_AUTO_DETACH_TIME = "auto_detach_time" +DOMAIN = "servo" MULTI_CONF = True CONFIG_SCHEMA = cv.Schema( { diff --git a/esphome/components/servo/servo.cpp b/esphome/components/servo/servo.cpp index 8d5344cf44..a415fdb8f7 100644 --- a/esphome/components/servo/servo.cpp +++ b/esphome/components/servo/servo.cpp @@ -5,7 +5,7 @@ namespace esphome::servo { -static const char *const TAG = "servo"; +ESPHOME_LOG_TAG(TAG, "servo"); uint32_t global_servo_id = 1911044085ULL; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/sfa30/__init__.py b/esphome/components/sfa30/__init__.py index 28b665906f..d608a07f79 100644 --- a/esphome/components/sfa30/__init__.py +++ b/esphome/components/sfa30/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ghsensdev"] +DOMAIN = "sfa30" diff --git a/esphome/components/sfa30/sfa30.cpp b/esphome/components/sfa30/sfa30.cpp index 960806e98b..efa3d8dc61 100644 --- a/esphome/components/sfa30/sfa30.cpp +++ b/esphome/components/sfa30/sfa30.cpp @@ -3,7 +3,7 @@ namespace esphome::sfa30 { -static const char *const TAG = "sfa30"; +ESPHOME_LOG_TAG(TAG, "sfa30"); static const uint16_t SFA30_CMD_GET_DEVICE_MARKING = 0xD060; static const uint16_t SFA30_CMD_START_CONTINUOUS_MEASUREMENTS = 0x0006; diff --git a/esphome/components/sfa40/__init__.py b/esphome/components/sfa40/__init__.py index 79568d458a..077f0d2e9f 100644 --- a/esphome/components/sfa40/__init__.py +++ b/esphome/components/sfa40/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@NoQuarrel"] +DOMAIN = "sfa40" diff --git a/esphome/components/sfa40/sfa40.cpp b/esphome/components/sfa40/sfa40.cpp index 0d6bee3f9a..31a25a4f38 100644 --- a/esphome/components/sfa40/sfa40.cpp +++ b/esphome/components/sfa40/sfa40.cpp @@ -4,7 +4,7 @@ namespace esphome::sfa40 { -static const char *const TAG = "sfa40"; +ESPHOME_LOG_TAG(TAG, "sfa40"); // SFA40 Datasheet: https://sensirion.com/media/documents/5B06EDD9/69F84BD8/Sensirion_Datasheet_SFA40.pdf diff --git a/esphome/components/sgp30/__init__.py b/esphome/components/sgp30/__init__.py index e69de29bb2..4754f35d59 100644 --- a/esphome/components/sgp30/__init__.py +++ b/esphome/components/sgp30/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sgp30" 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/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index fd007c92cc..72827ad51b 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -8,7 +8,7 @@ namespace esphome::sgp30 { -static const char *const TAG = "sgp30"; +ESPHOME_LOG_TAG(TAG, "sgp30"); static const uint16_t SGP30_CMD_GET_SERIAL_ID = 0x3682; static const uint16_t SGP30_CMD_GET_FEATURESET = 0x202f; diff --git a/esphome/components/sgp40/__init__.py b/esphome/components/sgp40/__init__.py index e69de29bb2..f18cc0d930 100644 --- a/esphome/components/sgp40/__init__.py +++ b/esphome/components/sgp40/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sgp40" diff --git a/esphome/components/sgp4x/__init__.py b/esphome/components/sgp4x/__init__.py index e69de29bb2..f8030fdae0 100644 --- a/esphome/components/sgp4x/__init__.py +++ b/esphome/components/sgp4x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sgp4x" 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/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 0cf5c31483..854dddba05 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -7,7 +7,7 @@ namespace esphome::sgp4x { -static const char *const TAG = "sgp4x"; +ESPHOME_LOG_TAG(TAG, "sgp4x"); void SGP4xComponent::setup() { // Serial Number identification diff --git a/esphome/components/sh1122_base/__init__.py b/esphome/components/sh1122_base/__init__.py new file mode 100644 index 0000000000..4f2b026a5e --- /dev/null +++ b/esphome/components/sh1122_base/__init__.py @@ -0,0 +1,43 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import display +import esphome.config_validation as cv +from esphome.const import CONF_BRIGHTNESS, CONF_LAMBDA, CONF_MODEL, CONF_RESET_PIN +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +CODEOWNERS = ["@pl4nkton"] +DOMAIN = "sh1122_base" + +sh1122_base_ns = cg.esphome_ns.namespace("sh1122_base") +SH1122 = sh1122_base_ns.class_("SH1122", cg.PollingComponent, display.DisplayBuffer) +SH1122Model = sh1122_base_ns.enum("SH1122Model") + +MODELS = { + "SH1122_256X64": SH1122Model.SH1122_MODEL_256_64, +} + +SH1122_MODEL = cv.enum(MODELS, upper=True, space="_") + +SH1122_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( + { + cv.Optional(CONF_MODEL, default="SH1122_256X64"): SH1122_MODEL, + cv.Optional(CONF_RESET_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, + } +).extend(cv.polling_component_schema("1s")) + + +async def setup_sh1122(var: MockObj, config: ConfigType) -> None: + await display.register_display(var, config) + + cg.add(var.set_model(config[CONF_MODEL])) + if (reset_pin := config.get(CONF_RESET_PIN)) is not None: + reset = await cg.gpio_pin_expression(reset_pin) + cg.add(var.set_reset_pin(reset)) + cg.add(var.init_brightness(config[CONF_BRIGHTNESS])) + if (lambda_config := config.get(CONF_LAMBDA)) is not None: + lambda_ = await cg.process_lambda( + lambda_config, [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/sh1122_base/sh1122_base.cpp b/esphome/components/sh1122_base/sh1122_base.cpp new file mode 100644 index 0000000000..72d5e44235 --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.cpp @@ -0,0 +1,146 @@ +#include "sh1122_base.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::sh1122_base { + +ESPHOME_LOG_TAG(TAG, "sh1122"); + +static constexpr uint8_t SH1122_MAX_CONTRAST = 255; +static constexpr uint8_t SH1122_COLORMASK = 0x0f; +static constexpr uint8_t SH1122_COLORSHIFT = 4; +static constexpr uint8_t SH1122_PIXELSPERBYTE = 2; + +static constexpr uint8_t SH1122_SETLOWCOLUMNADDRESS = 0x00; +static constexpr uint8_t SH1122_SETHIGHCOLUMNADDRESS = 0x10; +static constexpr uint8_t SH1122_SETDISCHARGEVSLLEVEL = 0x30; +static constexpr uint8_t SH1122_SETDISPLAYSTARTLINE = 0x40; +static constexpr uint8_t SH1122_SETCONTRASTCURRENT = 0x81; +static constexpr uint8_t SH1122_SETSEGMENTREMAP = 0xA0; +static constexpr uint8_t SH1122_SETDISPLAYOFFON = 0xA4; +static constexpr uint8_t SH1122_SETNORMALDISPLAY = 0xA6; +static constexpr uint8_t SH1122_SETMULTIPLEXRATIO = 0xA8; +static constexpr uint8_t SH1122_DCDCSETTING = 0xAD; +static constexpr uint8_t SH1122_SETDISPLAYOFF = 0xAE; +static constexpr uint8_t SH1122_SETDISPLAYON = 0xAF; +static constexpr uint8_t SH1122_SETROWADDRESS = 0xB0; +static constexpr uint8_t SH1122_SETSCANDIRECTION = 0xC0; +static constexpr uint8_t SH1122_SETDISPLAYOFFSET = 0xD3; +static constexpr uint8_t SH1122_SETCLOCKDIVIDER = 0xD5; +static constexpr uint8_t SH1122_SETDISCHARGEPRECHARGEPERIOD = 0xD9; +static constexpr uint8_t SH1122_SETVCOMDESELECTLEVEL = 0xDB; +static constexpr uint8_t SH1122_SETVSEGMLEVEL = 0xDC; + +void SH1122::setup() { + this->init_internal_(this->get_buffer_length_()); + if (this->buffer_ == nullptr) { + this->mark_failed(); + return; + } + + this->turn_off(); + this->command2_(SH1122_SETCLOCKDIVIDER, 0x50); + this->command2_(SH1122_SETMULTIPLEXRATIO, 0x3F); + this->command2_(SH1122_SETDISPLAYOFFSET, 0x00); + this->command2_(SH1122_SETROWADDRESS, 0x00); + this->command_(SH1122_SETDISPLAYSTARTLINE | 32); // the panel's rows start half way down the 128 line RAM + this->command_(SH1122_SETDISCHARGEVSLLEVEL); + this->command2_(SH1122_DCDCSETTING, 0x81); + this->command_(SH1122_SETSEGMENTREMAP | 0x01); // column 0 on the right + this->command_(SH1122_SETSCANDIRECTION | 0x08); // rows scanned bottom up + this->command2_(SH1122_SETDISCHARGEPRECHARGEPERIOD, 0x28); + this->command2_(SH1122_SETVCOMDESELECTLEVEL, 0x35); + this->command2_(SH1122_SETVSEGMLEVEL, 0x35); + this->command_(SH1122_SETNORMALDISPLAY); + this->command_(SH1122_SETHIGHCOLUMNADDRESS); + this->command_(SH1122_SETLOWCOLUMNADDRESS); + this->command_(SH1122_SETDISPLAYOFFON); + this->set_brightness(this->brightness_); + this->display(); // the buffer starts cleared; push it so no power-on garbage shows + this->turn_on(); +} +void SH1122::display() { + // Return the RAM pointer to the top left corner in one transfer, then stream the frame + const uint8_t set_address[] = {SH1122_SETHIGHCOLUMNADDRESS, SH1122_SETLOWCOLUMNADDRESS, SH1122_SETROWADDRESS, 0x00}; + this->write_command(set_address, sizeof(set_address)); + this->write_display_data(); +} +void SH1122::update() { + this->do_update_(); + this->display(); +} +void SH1122::set_brightness(float brightness) { + this->brightness_ = clamp(brightness, 0.0F, 1.0F); + this->command2_(SH1122_SETCONTRASTCURRENT, int(SH1122_MAX_CONTRAST * (this->brightness_))); +} +void SH1122::turn_on() { + this->command_(SH1122_SETDISPLAYON); + this->is_on_ = true; +} +void SH1122::turn_off() { + this->command_(SH1122_SETDISPLAYOFF); + this->is_on_ = false; +} +int SH1122::get_height_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 64; + default: + return 0; + } +} +int SH1122::get_width_internal() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return 256; + default: + return 0; + } +} +size_t SH1122::get_buffer_length_() { + return size_t(this->get_width_internal()) * size_t(this->get_height_internal()) / SH1122_PIXELSPERBYTE; +} +void HOT SH1122::draw_absolute_pixel_internal(int x, int y, Color color) { + const int width = this->get_width_internal(); + if (x >= width || x < 0 || y >= this->get_height_internal() || y < 0) + return; + // Two pixels per byte, the left one in the high nibble + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color) & SH1122_COLORMASK; + const uint16_t pos = (x / SH1122_PIXELSPERBYTE) + (y * width / SH1122_PIXELSPERBYTE); + const uint8_t shift = (1u - (x % SH1122_PIXELSPERBYTE)) * SH1122_COLORSHIFT; + this->buffer_[pos] = (this->buffer_[pos] & (static_cast(~SH1122_COLORMASK) >> shift)) | (color4 << shift); +} +void SH1122::fill(Color color) { + // If clipping is active, fall back to base implementation + if (this->get_clipping().is_set()) { + Display::fill(color); + return; + } + const uint32_t color4 = display::ColorUtil::color_to_grayscale4(color); + const uint8_t fill = (color4 & SH1122_COLORMASK) | ((color4 & SH1122_COLORMASK) << SH1122_COLORSHIFT); + memset(this->buffer_, fill, this->get_buffer_length_()); +} +void SH1122::init_reset_() { + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(true); + delay(1); + // Trigger Reset + this->reset_pin_->digital_write(false); + delay(10); + // Wake up + this->reset_pin_->digital_write(true); + } +} +const LogString *SH1122::model_str_() { + switch (this->model_) { + case SH1122_MODEL_256_64: + return LOG_STR("SH1122 256x64"); + default: + return LOG_STR("Unknown"); + } +} + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_base/sh1122_base.h b/esphome/components/sh1122_base/sh1122_base.h new file mode 100644 index 0000000000..c3e4719d39 --- /dev/null +++ b/esphome/components/sh1122_base/sh1122_base.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/components/display/display_buffer.h" + +namespace esphome::sh1122_base { + +enum SH1122Model : uint8_t { + SH1122_MODEL_256_64 = 0, +}; + +class SH1122 : public display::DisplayBuffer { + public: + void setup() override; + + void display(); + + void update() override; + + void set_model(SH1122Model model) { this->model_ = model; } + void set_reset_pin(GPIOPin *reset_pin) { this->reset_pin_ = reset_pin; } + void init_brightness(float brightness) { this->brightness_ = brightness; } + void set_brightness(float brightness); + bool is_on() { return this->is_on_; } + void turn_on(); + void turn_off(); + + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + void fill(Color color) override; + + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_GRAYSCALE; } + + protected: + /// Sends a command byte and its arguments; the SH1122 takes command arguments with D/C low as well + virtual void write_command(const uint8_t *bytes, size_t len) = 0; + virtual void write_display_data() = 0; + void command_(uint8_t value) { this->write_command(&value, 1); } + void command2_(uint8_t value, uint8_t data) { + const uint8_t bytes[2] = {value, data}; + this->write_command(bytes, 2); + } + void init_reset_(); + + void draw_absolute_pixel_internal(int x, int y, Color color) override; + + int get_height_internal() override; + int get_width_internal() override; + size_t get_buffer_length_(); + const LogString *model_str_(); + + GPIOPin *reset_pin_{nullptr}; + float brightness_{1.0}; + SH1122Model model_{SH1122_MODEL_256_64}; + bool is_on_{false}; +}; + +} // namespace esphome::sh1122_base diff --git a/esphome/components/sh1122_spi/__init__.py b/esphome/components/sh1122_spi/__init__.py new file mode 100644 index 0000000000..4ed7022267 --- /dev/null +++ b/esphome/components/sh1122_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sh1122_spi" diff --git a/esphome/components/sh1122_spi/display.py b/esphome/components/sh1122_spi/display.py new file mode 100644 index 0000000000..36acb9fd59 --- /dev/null +++ b/esphome/components/sh1122_spi/display.py @@ -0,0 +1,37 @@ +from esphome import pins +import esphome.codegen as cg +from esphome.components import sh1122_base, spi +import esphome.config_validation as cv +from esphome.const import CONF_DC_PIN, CONF_ID, CONF_LAMBDA, CONF_PAGES +from esphome.types import ConfigType + +from . import DOMAIN + +CODEOWNERS = ["@pl4nkton"] + +AUTO_LOAD = ["sh1122_base"] +DEPENDENCIES = ["spi"] + +sh1122_spi = cg.esphome_ns.namespace("sh1122_spi") +SPISH1122 = sh1122_spi.class_("SPISH1122", sh1122_base.SH1122, spi.SPIDevice) + +CONFIG_SCHEMA = cv.All( + sh1122_base.SH1122_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SPISH1122), + cv.Required(CONF_DC_PIN): pins.gpio_output_pin_schema, + } + ).extend(spi.spi_device_schema(cs_pin_required=False)), + cv.has_at_most_one_key(CONF_PAGES, CONF_LAMBDA), +) + +FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( + DOMAIN, require_miso=False, require_mosi=True +) + + +async def to_code(config: ConfigType) -> None: + dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) + var = cg.new_Pvariable(config[CONF_ID], dc) + await sh1122_base.setup_sh1122(var, config) + await spi.register_spi_device(var, config, write_only=True) diff --git a/esphome/components/sh1122_spi/sh1122_spi.cpp b/esphome/components/sh1122_spi/sh1122_spi.cpp new file mode 100644 index 0000000000..2e925aea29 --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.cpp @@ -0,0 +1,42 @@ +#include "sh1122_spi.h" +#include "esphome/core/log.h" + +namespace esphome::sh1122_spi { + +ESPHOME_LOG_TAG(TAG, "sh1122_spi"); + +void SPISH1122::setup() { + this->spi_setup(); + this->dc_pin_->setup(); + + this->init_reset_(); + delay(500); // NOLINT + SH1122::setup(); +} +void SPISH1122::dump_config() { + LOG_DISPLAY("", "SPI SH1122", this); + ESP_LOGCONFIG(TAG, + " Model: %s\n" + " Initial Brightness: %.2f", + LOG_STR_ARG(this->model_str_()), this->brightness_); + LOG_PIN(" CS Pin: ", this->cs_); + LOG_PIN(" DC Pin: ", this->dc_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); + LOG_UPDATE_INTERVAL(this); +} +// enable() and disable() drive the CS pin. The D/C setup time is a few tens of nanoseconds, so the +// GPIO write followed by the CS edge already satisfies it; no delay is needed. +void SPISH1122::write_command(const uint8_t *bytes, size_t len) { + this->dc_pin_->digital_write(false); + this->enable(); + this->write_array(bytes, len); + this->disable(); +} +void HOT SPISH1122::write_display_data() { + this->dc_pin_->digital_write(true); + this->enable(); + this->write_array(this->buffer_, this->get_buffer_length_()); + this->disable(); +} + +} // namespace esphome::sh1122_spi diff --git a/esphome/components/sh1122_spi/sh1122_spi.h b/esphome/components/sh1122_spi/sh1122_spi.h new file mode 100644 index 0000000000..f5da209fe2 --- /dev/null +++ b/esphome/components/sh1122_spi/sh1122_spi.h @@ -0,0 +1,25 @@ +#pragma once + +#include "esphome/components/sh1122_base/sh1122_base.h" +#include "esphome/components/spi/spi.h" + +namespace esphome::sh1122_spi { + +class SPISH1122 final : public sh1122_base::SH1122, + public spi::SPIDevice { + public: + explicit SPISH1122(GPIOPin *dc_pin) : dc_pin_(dc_pin) {} + + void setup() override; + + void dump_config() override; + + protected: + void write_command(const uint8_t *bytes, size_t len) override; + void write_display_data() override; + + GPIOPin *dc_pin_; +}; + +} // namespace esphome::sh1122_spi diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index 5db0e77b76..5856d230a2 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -5,6 +5,7 @@ from esphome.helpers import IS_MACOS from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] +DOMAIN = "sha256" sha256_ns = cg.esphome_ns.namespace("sha256") diff --git a/esphome/components/shelly_dimmer/__init__.py b/esphome/components/shelly_dimmer/__init__.py index accefbbc34..72586d1637 100644 --- a/esphome/components/shelly_dimmer/__init__.py +++ b/esphome/components/shelly_dimmer/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@rnauber", "@edge90"] +DOMAIN = "shelly_dimmer" diff --git a/esphome/components/shelly_dimmer/shelly_dimmer.cpp b/esphome/components/shelly_dimmer/shelly_dimmer.cpp index b69e417591..eedf7ff7ce 100644 --- a/esphome/components/shelly_dimmer/shelly_dimmer.cpp +++ b/esphome/components/shelly_dimmer/shelly_dimmer.cpp @@ -19,7 +19,7 @@ namespace { -constexpr char TAG[] = "shelly_dimmer"; +ESPHOME_LOG_TAG(TAG, "shelly_dimmer"); constexpr uint8_t SHELLY_DIMMER_ACK_TIMEOUT = 200; // ms constexpr uint8_t SHELLY_DIMMER_MAX_RETRIES = 3; diff --git a/esphome/components/shelly_dimmer/stm32flash.cpp b/esphome/components/shelly_dimmer/stm32flash.cpp index beb015851e..c33876e494 100644 --- a/esphome/components/shelly_dimmer/stm32flash.cpp +++ b/esphome/components/shelly_dimmer/stm32flash.cpp @@ -108,7 +108,7 @@ constexpr uint8_t STM_OBL_LAUNCH_CODE[] = { constexpr uint32_t STM_OBL_LAUNCH_CODE_SIZE = sizeof(STM_OBL_LAUNCH_CODE); -constexpr char TAG[] = "stm32flash"; +ESPHOME_LOG_TAG(TAG, "stm32flash"); } // Anonymous namespace diff --git a/esphome/components/sht3xd/__init__.py b/esphome/components/sht3xd/__init__.py index e69de29bb2..f58b3fb150 100644 --- a/esphome/components/sht3xd/__init__.py +++ b/esphome/components/sht3xd/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sht3xd" 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/sht3xd/sht3xd.cpp b/esphome/components/sht3xd/sht3xd.cpp index eba6be65d5..672d119aed 100644 --- a/esphome/components/sht3xd/sht3xd.cpp +++ b/esphome/components/sht3xd/sht3xd.cpp @@ -3,7 +3,7 @@ namespace esphome::sht3xd { -static const char *const TAG = "sht3xd"; +ESPHOME_LOG_TAG(TAG, "sht3xd"); // https://sensirion.com/media/documents/E5762713/63D103C2/Sensirion_electronic_identification_code_SHT3x.pdf // indicates two possible read serial number registers either with clock stretching enabled or disabled. diff --git a/esphome/components/sht4x/__init__.py b/esphome/components/sht4x/__init__.py index e69de29bb2..2e9124bdab 100644 --- a/esphome/components/sht4x/__init__.py +++ b/esphome/components/sht4x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sht4x" diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 4a3df5a91f..45de59fe0d 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -4,7 +4,7 @@ namespace esphome::sht4x { -static const char *const TAG = "sht4x"; +ESPHOME_LOG_TAG(TAG, "sht4x"); static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0}; static const uint8_t SERIAL_NUMBER_COMMAND = 0x89; diff --git a/esphome/components/shtcx/__init__.py b/esphome/components/shtcx/__init__.py index e69de29bb2..30c06bdaec 100644 --- a/esphome/components/shtcx/__init__.py +++ b/esphome/components/shtcx/__init__.py @@ -0,0 +1 @@ +DOMAIN = "shtcx" 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/shtcx/shtcx.cpp b/esphome/components/shtcx/shtcx.cpp index 9ec0a2cdb7..68f2c4ea21 100644 --- a/esphome/components/shtcx/shtcx.cpp +++ b/esphome/components/shtcx/shtcx.cpp @@ -4,7 +4,7 @@ namespace esphome::shtcx { -static const char *const TAG = "shtcx"; +ESPHOME_LOG_TAG(TAG, "shtcx"); static constexpr uint16_t SHTCX_COMMAND_SLEEP = 0xB098; static constexpr uint16_t SHTCX_COMMAND_WAKEUP = 0x3517; diff --git a/esphome/components/shutdown/__init__.py b/esphome/components/shutdown/__init__.py index 480a6f3e31..949ea69bb9 100644 --- a/esphome/components/shutdown/__init__.py +++ b/esphome/components/shutdown/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core", "@jsuanet"] +DOMAIN = "shutdown" diff --git a/esphome/components/shutdown/button/shutdown_button.cpp b/esphome/components/shutdown/button/shutdown_button.cpp index 6394c6c14e..7a1380469b 100644 --- a/esphome/components/shutdown/button/shutdown_button.cpp +++ b/esphome/components/shutdown/button/shutdown_button.cpp @@ -12,7 +12,7 @@ namespace esphome::shutdown { -static const char *const TAG = "shutdown.button"; +ESPHOME_LOG_TAG(TAG, "shutdown.button"); void ShutdownButton::dump_config() { LOG_BUTTON("", "Shutdown Button", this); } void ShutdownButton::press_action() { diff --git a/esphome/components/shutdown/switch/shutdown_switch.cpp b/esphome/components/shutdown/switch/shutdown_switch.cpp index a44a572aa1..6e0a70ff82 100644 --- a/esphome/components/shutdown/switch/shutdown_switch.cpp +++ b/esphome/components/shutdown/switch/shutdown_switch.cpp @@ -12,7 +12,7 @@ namespace esphome::shutdown { -static const char *const TAG = "shutdown.switch"; +ESPHOME_LOG_TAG(TAG, "shutdown.switch"); void ShutdownSwitch::dump_config() { LOG_SWITCH("", "Shutdown Switch", this); } void ShutdownSwitch::write_state(bool state) { diff --git a/esphome/components/siemens_ira211/__init__.py b/esphome/components/siemens_ira211/__init__.py new file mode 100644 index 0000000000..1407aff81e --- /dev/null +++ b/esphome/components/siemens_ira211/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@fonix232"] +DOMAIN = "siemens_ira211" diff --git a/esphome/components/siemens_ira211/climate.py b/esphome/components/siemens_ira211/climate.py new file mode 100644 index 0000000000..16345e70f1 --- /dev/null +++ b/esphome/components/siemens_ira211/climate.py @@ -0,0 +1,17 @@ +import esphome.codegen as cg +from esphome.components import climate_ir, remote_base +from esphome.types import ConfigType + +AUTO_LOAD = ["climate_ir"] + +siemens_ira211_ns = cg.esphome_ns.namespace("siemens_ira211") +SiemensIRA211Climate = siemens_ira211_ns.class_( + "SiemensIRA211Climate", climate_ir.ClimateIR +) + +CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SiemensIRA211Climate) + + +async def to_code(config: ConfigType) -> None: + remote_base.request_protocol("ira211") # used from C++ + await climate_ir.new_climate_ir(config) diff --git a/esphome/components/siemens_ira211/siemens_ira211.cpp b/esphome/components/siemens_ira211/siemens_ira211.cpp new file mode 100644 index 0000000000..e332134eac --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.cpp @@ -0,0 +1,167 @@ +#include "siemens_ira211.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::siemens_ira211 { + +using namespace remote_base; + +struct PresetMapping { + climate::ClimatePreset preset; + IRA211Mode mode; +}; + +static const PresetMapping PRESETS[] PROGMEM = { + {climate::CLIMATE_PRESET_COMFORT, IRA211Mode::IRA211_MODE_COMFORT}, + {climate::CLIMATE_PRESET_ECO, IRA211Mode::IRA211_MODE_TIMER}, +}; + +struct FanMapping { + climate::ClimateFanMode fan_mode; + IRA211Fan fan; +}; + +static const FanMapping FANS[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, IRA211Fan::IRA211_FAN_AUTO}, + {climate::CLIMATE_FAN_LOW, IRA211Fan::IRA211_FAN_LOW}, + {climate::CLIMATE_FAN_MEDIUM, IRA211Fan::IRA211_FAN_MEDIUM}, + {climate::CLIMATE_FAN_HIGH, IRA211Fan::IRA211_FAN_HIGH}, +}; + +// The tables live in flash, so entries are copied out before use +template static T load_entry(const T &slot) { + T entry; + progmem_memcpy(&entry, &slot, sizeof(T)); + return entry; +} + +// The first entry of each table is the fallback for values the other side does not know +static IRA211Mode to_ira211_mode(climate::ClimatePreset preset) { + for (const auto &slot : PRESETS) { + const auto entry = load_entry(slot); + if (entry.preset == preset) + return entry.mode; + } + return load_entry(PRESETS[0]).mode; +} + +static climate::ClimatePreset to_climate_preset(IRA211Mode mode) { + for (const auto &slot : PRESETS) { + const auto entry = load_entry(slot); + if (entry.mode == mode) + return entry.preset; + } + return load_entry(PRESETS[0]).preset; +} + +static IRA211Fan to_ira211_fan(climate::ClimateFanMode fan_mode) { + for (const auto &slot : FANS) { + const auto entry = load_entry(slot); + if (entry.fan_mode == fan_mode) + return entry.fan; + } + return load_entry(FANS[0]).fan; +} + +static climate::ClimateFanMode to_climate_fan_mode(IRA211Fan fan) { + for (const auto &slot : FANS) { + const auto entry = load_entry(slot); + if (entry.fan == fan) + return entry.fan_mode; + } + return load_entry(FANS[0]).fan_mode; +} + +void SiemensIRA211Climate::setup() { + // The thermostat decides between heating and cooling itself, so one mode stands for both + if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) { + this->modes_.erase(climate::CLIMATE_MODE_HEAT); + this->modes_.erase(climate::CLIMATE_MODE_COOL); + } + climate_ir::ClimateIR::setup(); + // Match the restored state, so the first command after a reboot does not send a needless POWER frame + this->is_on_ = this->mode != climate::CLIMATE_MODE_OFF; +} + +climate::ClimateMode SiemensIRA211Climate::active_mode_() const { + if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) + return climate::CLIMATE_MODE_HEAT_COOL; + if (this->modes_.count(climate::CLIMATE_MODE_HEAT)) + return climate::CLIMATE_MODE_HEAT; + return climate::CLIMATE_MODE_COOL; +} + +void SiemensIRA211Climate::transmit_frame_(IRA211Command command, IRA211Mode mode, IRA211Fan fan) { + IRA211Data data{}; + data.command = command; + data.set_temperature(this->target_temperature); + data.mode = mode; + data.fan = fan; + this->transmit_(data); +} + +void SiemensIRA211Climate::transmit_state() { + const IRA211Mode mode = to_ira211_mode(this->preset.value_or(climate::CLIMATE_PRESET_COMFORT)); + const IRA211Fan fan = to_ira211_fan(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)); + // Show the preset that is actually sent, so a fresh entity does not stay on "none" + this->preset = to_climate_preset(mode); + + if (this->mode == climate::CLIMATE_MODE_OFF) { + this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, IRA211Mode::IRA211_MODE_PROTECTION, fan); + this->is_on_ = false; + return; + } + if (!this->is_on_) { + // Turning on takes a POWER frame before the state is sent + this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, mode, fan); + this->is_on_ = true; + } + this->transmit_frame_(IRA211Command::IRA211_COMMAND_SYNC, mode, fan); +} + +void SiemensIRA211Climate::apply_mode_(IRA211Mode mode) { + // The thermostat reports "off" as the PROTECTION mode + if (mode == IRA211Mode::IRA211_MODE_PROTECTION) { + this->mode = climate::CLIMATE_MODE_OFF; + this->is_on_ = false; + return; + } + if (!this->is_on_) { + this->mode = this->active_mode_(); + this->is_on_ = true; + } + this->preset = to_climate_preset(mode); +} + +bool SiemensIRA211Climate::on_receive(RemoteReceiveData data) { + auto decoded = IRA211Protocol().decode(data); + if (!decoded.has_value()) + return false; + + const IRA211Data &frame = *decoded; + switch (frame.command) { + case IRA211Command::IRA211_COMMAND_SYNC: + case IRA211Command::IRA211_COMMAND_POWER: + this->apply_mode_(frame.mode); + this->target_temperature = frame.get_temperature(); + this->fan_mode = to_climate_fan_mode(frame.fan); + break; + case IRA211Command::IRA211_COMMAND_TEMP_UP: + case IRA211Command::IRA211_COMMAND_TEMP_DOWN: + this->target_temperature = frame.get_temperature(); + break; + case IRA211Command::IRA211_COMMAND_MODE: + this->apply_mode_(frame.mode); + break; + case IRA211Command::IRA211_COMMAND_FAN: + this->fan_mode = to_climate_fan_mode(frame.fan); + break; + default: + return false; + } + + this->publish_state(); + return true; +} + +} // namespace esphome::siemens_ira211 diff --git a/esphome/components/siemens_ira211/siemens_ira211.h b/esphome/components/siemens_ira211/siemens_ira211.h new file mode 100644 index 0000000000..dcba2ddcb9 --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.h @@ -0,0 +1,29 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" +#include "esphome/components/remote_base/ira211_protocol.h" + +namespace esphome::siemens_ira211 { + +class SiemensIRA211Climate final : public climate_ir::ClimateIR { + public: + void setup() override; + SiemensIRA211Climate() + : climate_ir::ClimateIR(remote_base::IRA211_MIN_TEMPERATURE, remote_base::IRA211_MAX_TEMPERATURE, 0.5f, false, + false, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {}, {climate::CLIMATE_PRESET_COMFORT, climate::CLIMATE_PRESET_ECO}) {} + + protected: + void transmit_state() override; + bool on_receive(remote_base::RemoteReceiveData data) override; + + void transmit_frame_(remote_base::IRA211Command command, remote_base::IRA211Mode mode, remote_base::IRA211Fan fan); + void apply_mode_(remote_base::IRA211Mode mode); + climate::ClimateMode active_mode_() const; + + bool is_on_{false}; +}; + +} // namespace esphome::siemens_ira211 diff --git a/esphome/components/sigma_delta_output/__init__.py b/esphome/components/sigma_delta_output/__init__.py index 3356e61bb2..2af211995b 100644 --- a/esphome/components/sigma_delta_output/__init__.py +++ b/esphome/components/sigma_delta_output/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Cat-Ion"] +DOMAIN = "sigma_delta_output" diff --git a/esphome/components/sigma_delta_output/sigma_delta_output.cpp b/esphome/components/sigma_delta_output/sigma_delta_output.cpp index 6cc131c68f..37412c5d9b 100644 --- a/esphome/components/sigma_delta_output/sigma_delta_output.cpp +++ b/esphome/components/sigma_delta_output/sigma_delta_output.cpp @@ -3,7 +3,7 @@ namespace esphome::sigma_delta_output { -static const char *const TAG = "output.sigma_delta"; +ESPHOME_LOG_TAG(TAG, "output.sigma_delta"); void SigmaDeltaOutput::setup() { if (this->pin_) diff --git a/esphome/components/sim800l/__init__.py b/esphome/components/sim800l/__init__.py index ae7ee6fa59..e20c091b75 100644 --- a/esphome/components/sim800l/__init__.py +++ b/esphome/components/sim800l/__init__.py @@ -6,20 +6,12 @@ from esphome.const import CONF_ID, CONF_MESSAGE DEPENDENCIES = ["uart"] CODEOWNERS = ["@glmnet"] +DOMAIN = "sim800l" 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 +82,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 +98,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 +111,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.cpp b/esphome/components/sim800l/sim800l.cpp index 13b9888e05..e75e5e95dd 100644 --- a/esphome/components/sim800l/sim800l.cpp +++ b/esphome/components/sim800l/sim800l.cpp @@ -5,7 +5,7 @@ namespace esphome::sim800l { -static const char *const TAG = "sim800l"; +ESPHOME_LOG_TAG(TAG, "sim800l"); const char ASCII_CR = 0x0D; const char ASCII_LF = 0x0A; 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/slow_pwm/__init__.py b/esphome/components/slow_pwm/__init__.py index e69de29bb2..c46da34e38 100644 --- a/esphome/components/slow_pwm/__init__.py +++ b/esphome/components/slow_pwm/__init__.py @@ -0,0 +1 @@ +DOMAIN = "slow_pwm" diff --git a/esphome/components/slow_pwm/slow_pwm_output.cpp b/esphome/components/slow_pwm/slow_pwm_output.cpp index e695ab9540..f41455bc80 100644 --- a/esphome/components/slow_pwm/slow_pwm_output.cpp +++ b/esphome/components/slow_pwm/slow_pwm_output.cpp @@ -5,7 +5,7 @@ namespace esphome::slow_pwm { -static const char *const TAG = "output.slow_pwm"; +ESPHOME_LOG_TAG(TAG, "output.slow_pwm"); void SlowPWMOutput::setup() { if (this->pin_) diff --git a/esphome/components/sm10bit_base/__init__.py b/esphome/components/sm10bit_base/__init__.py index 2b555a59df..05ba98562d 100644 --- a/esphome/components/sm10bit_base/__init__.py +++ b/esphome/components/sm10bit_base/__init__.py @@ -6,6 +6,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@Cossid"] +DOMAIN = "sm10bit_base" MULTI_CONF = True CONF_MAX_POWER_COLOR_CHANNELS = "max_power_color_channels" diff --git a/esphome/components/sm10bit_base/sm10bit_base.cpp b/esphome/components/sm10bit_base/sm10bit_base.cpp index 45de3c457d..d01ec5dbdb 100644 --- a/esphome/components/sm10bit_base/sm10bit_base.cpp +++ b/esphome/components/sm10bit_base/sm10bit_base.cpp @@ -3,7 +3,7 @@ namespace esphome::sm10bit_base { -static const char *const TAG = "sm10bit_base"; +ESPHOME_LOG_TAG(TAG, "sm10bit_base"); static const uint8_t SM10BIT_ADDR_STANDBY = 0x0; static const uint8_t SM10BIT_ADDR_START_3CH = 0x8; diff --git a/esphome/components/sm16716/__init__.py b/esphome/components/sm16716/__init__.py index 3fb9ac3f06..ae3c54ab7c 100644 --- a/esphome/components/sm16716/__init__.py +++ b/esphome/components/sm16716/__init__.py @@ -11,6 +11,7 @@ from esphome.const import ( from esphome.types import ConfigType AUTO_LOAD = ["output"] +DOMAIN = "sm16716" sm16716_ns = cg.esphome_ns.namespace("sm16716") SM16716 = sm16716_ns.class_("SM16716", cg.Component) diff --git a/esphome/components/sm16716/sm16716.cpp b/esphome/components/sm16716/sm16716.cpp index 59e9f1b712..647a40dc47 100644 --- a/esphome/components/sm16716/sm16716.cpp +++ b/esphome/components/sm16716/sm16716.cpp @@ -3,7 +3,7 @@ namespace esphome::sm16716 { -static const char *const TAG = "sm16716"; +ESPHOME_LOG_TAG(TAG, "sm16716"); void SM16716::setup() { this->data_pin_->setup(); diff --git a/esphome/components/sm2135/__init__.py b/esphome/components/sm2135/__init__.py index 537db5bbe4..c42ce4c8b3 100644 --- a/esphome/components/sm2135/__init__.py +++ b/esphome/components/sm2135/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["output"] CODEOWNERS = ["@BoukeHaarsma23", "@matika77", "@dd32"] +DOMAIN = "sm2135" sm2135_ns = cg.esphome_ns.namespace("sm2135") SM2135 = sm2135_ns.class_("SM2135", cg.Component) diff --git a/esphome/components/sm2135/sm2135.cpp b/esphome/components/sm2135/sm2135.cpp index 0086a63878..c8a4287b65 100644 --- a/esphome/components/sm2135/sm2135.cpp +++ b/esphome/components/sm2135/sm2135.cpp @@ -5,7 +5,7 @@ namespace esphome::sm2135 { -static const char *const TAG = "sm2135"; +ESPHOME_LOG_TAG(TAG, "sm2135"); static const uint8_t SM2135_ADDR_MC = 0xC0; // Max current register static const uint8_t SM2135_ADDR_CH = 0xC1; // RGB or CW channel select register diff --git a/esphome/components/sm2235/__init__.py b/esphome/components/sm2235/__init__.py index 95037fd275..803cd54be5 100644 --- a/esphome/components/sm2235/__init__.py +++ b/esphome/components/sm2235/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["sm10bit_base", "output"] CODEOWNERS = ["@Cossid"] +DOMAIN = "sm2235" MULTI_CONF = True sm2235_ns = cg.esphome_ns.namespace("sm2235") diff --git a/esphome/components/sm2235/sm2235.cpp b/esphome/components/sm2235/sm2235.cpp index e981bd0f71..3bd302047b 100644 --- a/esphome/components/sm2235/sm2235.cpp +++ b/esphome/components/sm2235/sm2235.cpp @@ -3,7 +3,7 @@ namespace esphome::sm2235 { -static const char *const TAG = "sm2235"; +ESPHOME_LOG_TAG(TAG, "sm2235"); void SM2235::setup() { this->data_pin_->setup(); diff --git a/esphome/components/sm2335/__init__.py b/esphome/components/sm2335/__init__.py index 145c5a0117..a9fdfcef39 100644 --- a/esphome/components/sm2335/__init__.py +++ b/esphome/components/sm2335/__init__.py @@ -5,6 +5,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["sm10bit_base", "output"] CODEOWNERS = ["@Cossid"] +DOMAIN = "sm2335" MULTI_CONF = True sm2335_ns = cg.esphome_ns.namespace("sm2335") diff --git a/esphome/components/sm2335/sm2335.cpp b/esphome/components/sm2335/sm2335.cpp index 93f1096800..6e0d7b3588 100644 --- a/esphome/components/sm2335/sm2335.cpp +++ b/esphome/components/sm2335/sm2335.cpp @@ -3,7 +3,7 @@ namespace esphome::sm2335 { -static const char *const TAG = "sm2335"; +ESPHOME_LOG_TAG(TAG, "sm2335"); void SM2335::setup() { this->data_pin_->setup(); diff --git a/esphome/components/sm300d2/__init__.py b/esphome/components/sm300d2/__init__.py index e69de29bb2..da0a0637ed 100644 --- a/esphome/components/sm300d2/__init__.py +++ b/esphome/components/sm300d2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sm300d2" 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/sm300d2/sm300d2.cpp b/esphome/components/sm300d2/sm300d2.cpp index 882959a454..a7959a0999 100644 --- a/esphome/components/sm300d2/sm300d2.cpp +++ b/esphome/components/sm300d2/sm300d2.cpp @@ -3,7 +3,7 @@ namespace esphome::sm300d2 { -static const char *const TAG = "sm300d2"; +ESPHOME_LOG_TAG(TAG, "sm300d2"); static const uint8_t SM300D2_RESPONSE_LENGTH = 17; void SM300D2Sensor::update() { @@ -83,13 +83,7 @@ void SM300D2Sensor::update() { this->humidity_sensor_->publish_state(humidity); } -uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { - uint8_t sum = 0; - for (int i = 0; i < (SM300D2_RESPONSE_LENGTH - 1); i++) { - sum += *ptr++; - } - return sum; -} +uint16_t SM300D2Sensor::sm300d2_checksum_(uint8_t *ptr) { return sum_bytes(ptr, SM300D2_RESPONSE_LENGTH - 1); } void SM300D2Sensor::dump_config() { ESP_LOGCONFIG(TAG, "SM300D2:"); diff --git a/esphome/components/sml/__init__.py b/esphome/components/sml/__init__.py index 07ca5bf444..35ca231f02 100644 --- a/esphome/components/sml/__init__.py +++ b/esphome/components/sml/__init__.py @@ -9,6 +9,7 @@ from esphome.const import CONF_ID, CONF_ON_DATA from esphome.types import ConfigType CODEOWNERS = ["@alengwenus"] +DOMAIN = "sml" DEPENDENCIES = ["uart"] diff --git a/esphome/components/sml/sensor/sml_sensor.cpp b/esphome/components/sml/sensor/sml_sensor.cpp index 047a13e88d..9861edcf8f 100644 --- a/esphome/components/sml/sensor/sml_sensor.cpp +++ b/esphome/components/sml/sensor/sml_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::sml { -static const char *const TAG = "sml_sensor"; +ESPHOME_LOG_TAG(TAG, "sml_sensor"); SmlSensor::SmlSensor(std::string server_id, std::string obis_code) : SmlListener(std::move(server_id), std::move(obis_code)) {} diff --git a/esphome/components/sml/sml.cpp b/esphome/components/sml/sml.cpp index bacfbcccef..a4bc4373d7 100644 --- a/esphome/components/sml/sml.cpp +++ b/esphome/components/sml/sml.cpp @@ -5,7 +5,7 @@ namespace esphome::sml { -static const char *const TAG = "sml"; +ESPHOME_LOG_TAG(TAG, "sml"); const char START_BYTES_DETECTED = 1; const char END_BYTES_DETECTED = 2; diff --git a/esphome/components/sml/text_sensor/sml_text_sensor.cpp b/esphome/components/sml/text_sensor/sml_text_sensor.cpp index d0965e3dca..9bd31923ec 100644 --- a/esphome/components/sml/text_sensor/sml_text_sensor.cpp +++ b/esphome/components/sml/text_sensor/sml_text_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::sml { -static const char *const TAG = "sml_text_sensor"; +ESPHOME_LOG_TAG(TAG, "sml_text_sensor"); SmlTextSensor::SmlTextSensor(std::string server_id, std::string obis_code, SmlType format) : SmlListener(std::move(server_id), std::move(obis_code)), format_(format) {} diff --git a/esphome/components/smt100/__init__.py b/esphome/components/smt100/__init__.py index 44ed591400..572df68d99 100644 --- a/esphome/components/smt100/__init__.py +++ b/esphome/components/smt100/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@piechade"] +DOMAIN = "smt100" 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/smt100/smt100.cpp b/esphome/components/smt100/smt100.cpp index 2889a9fb4d..7785bbca2d 100644 --- a/esphome/components/smt100/smt100.cpp +++ b/esphome/components/smt100/smt100.cpp @@ -3,7 +3,7 @@ namespace esphome::smt100 { -static const char *const TAG = "smt100"; +ESPHOME_LOG_TAG(TAG, "smt100"); void SMT100Component::update() { ESP_LOGV(TAG, "Sending measurement request"); diff --git a/esphome/components/sn74hc165/__init__.py b/esphome/components/sn74hc165/__init__.py index 4f21312fec..af566cebda 100644 --- a/esphome/components/sn74hc165/__init__.py +++ b/esphome/components/sn74hc165/__init__.py @@ -15,6 +15,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz"] DEPENDENCIES = [] +DOMAIN = "sn74hc165" MULTI_CONF = True sn74hc165_ns = cg.esphome_ns.namespace("sn74hc165") diff --git a/esphome/components/sn74hc165/sn74hc165.cpp b/esphome/components/sn74hc165/sn74hc165.cpp index fde789e90c..318390e8e2 100644 --- a/esphome/components/sn74hc165/sn74hc165.cpp +++ b/esphome/components/sn74hc165/sn74hc165.cpp @@ -3,7 +3,7 @@ namespace esphome::sn74hc165 { -static const char *const TAG = "sn74hc165"; +ESPHOME_LOG_TAG(TAG, "sn74hc165"); void SN74HC165Component::setup() { // initialize pins diff --git a/esphome/components/sn74hc595/__init__.py b/esphome/components/sn74hc595/__init__.py index 367b65176b..78ed703ed9 100644 --- a/esphome/components/sn74hc595/__init__.py +++ b/esphome/components/sn74hc595/__init__.py @@ -15,6 +15,7 @@ from esphome.const import ( from esphome.cpp_generator import MockObj from esphome.types import ConfigType +DOMAIN = "sn74hc595" MULTI_CONF = True sn74hc595_ns = cg.esphome_ns.namespace("sn74hc595") diff --git a/esphome/components/sn74hc595/sn74hc595.cpp b/esphome/components/sn74hc595/sn74hc595.cpp index 710e51ad12..4691ffcf6d 100644 --- a/esphome/components/sn74hc595/sn74hc595.cpp +++ b/esphome/components/sn74hc595/sn74hc595.cpp @@ -4,7 +4,7 @@ namespace esphome::sn74hc595 { -static const char *const TAG = "sn74hc595"; +ESPHOME_LOG_TAG(TAG, "sn74hc595"); void SN74HC595Component::pre_setup_() { if (this->have_oe_pin_) { // disable output 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/display/snapshot_display.cpp b/esphome/components/snapshot/display/snapshot_display.cpp index 6297e3e18f..6618d9ae74 100644 --- a/esphome/components/snapshot/display/snapshot_display.cpp +++ b/esphome/components/snapshot/display/snapshot_display.cpp @@ -7,7 +7,7 @@ namespace esphome::snapshot { -static const char *const TAG = "snapshot.display"; +ESPHOME_LOG_TAG(TAG, "snapshot.display"); namespace { 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 5aa0bcd0e1..58406f17ff 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,17 +10,19 @@ #include #include #include +#include #include #include #include #include #include +#include namespace esphome::snapshot { namespace { -constexpr const char *const TAG = "snapshot"; +ESPHOME_LOG_TAG(TAG, "snapshot"); // Longest name we will build a path from. NAME_MAX is 255 and we may append a collision suffix. constexpr size_t MAX_NAME_LENGTH = 200; @@ -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() ? LOG_STR_LITERAL("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/sntp/__init__.py b/esphome/components/sntp/__init__.py index e69de29bb2..481183e020 100644 --- a/esphome/components/sntp/__init__.py +++ b/esphome/components/sntp/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sntp" diff --git a/esphome/components/sntp/sntp_component.cpp b/esphome/components/sntp/sntp_component.cpp index f8d48b4098..de951483bb 100644 --- a/esphome/components/sntp/sntp_component.cpp +++ b/esphome/components/sntp/sntp_component.cpp @@ -11,7 +11,7 @@ namespace esphome::sntp { -static const char *const TAG = "sntp"; +ESPHOME_LOG_TAG(TAG, "sntp"); #if defined(USE_ESP32) SNTPComponent *SNTPComponent::instance = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index 9d849f9fe9..9365339473 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -2,13 +2,13 @@ import logging import esphome.codegen as cg from esphome.components import time as time_ +from esphome.components.time import DOMAIN as TIME_DOMAIN from esphome.config_helpers import merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_PLATFORM, CONF_SERVERS, - CONF_TIME, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -20,11 +20,12 @@ from esphome.core import CORE import esphome.final_validate as fv from esphome.types import ConfigType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["network"] -CONF_SNTP = "sntp" sntp_ns = cg.esphome_ns.namespace("sntp") SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock) @@ -35,13 +36,13 @@ DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"] def _sntp_final_validate(config: ConfigType) -> None: """Merge multiple SNTP instances into one, similar to OTA merging behavior.""" full_conf = fv.full_config.get() - time_confs = full_conf.get(CONF_TIME, []) + time_confs = full_conf.get(TIME_DOMAIN, []) sntp_configs: list[ConfigType] = [] other_time_configs: list[ConfigType] = [] for time_conf in time_confs: - if time_conf.get(CONF_PLATFORM) == CONF_SNTP: + if time_conf.get(CONF_PLATFORM) == DOMAIN: sntp_configs.append(time_conf) else: other_time_configs.append(time_conf) @@ -81,7 +82,7 @@ def _sntp_final_validate(config: ConfigType) -> None: # Replace time configs with merged SNTP + other time platforms other_time_configs.append(merged) - full_conf[CONF_TIME] = other_time_configs + full_conf[TIME_DOMAIN] = other_time_configs fv.full_config.set(full_conf) diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index edebed5d40..effa7d864f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -1,17 +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"] +DOMAIN = "socket" + +socket_ns = cg.esphome_ns.namespace("socket") +Ipv4AllowEntry = socket_ns.struct("Ipv4AllowEntry") CONF_IMPLEMENTATION = "implementation" IMPLEMENTATION_LWIP_TCP = "lwip_tcp" @@ -140,6 +146,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 +265,17 @@ 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_common_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "lwip_raw_tcp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", + "lwip_raw_udp_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/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 1b5ea9ebcd..e2b5de6f10 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -144,6 +144,9 @@ class BSDSocketImpl { int get_fd() const { return this->fd_; } + /// UDP rx drop counter parity with LWIPRawUDPImpl; drops are not counted here. + uint16_t get_rx_dropped() const { return 0; } + protected: // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This // replaces a separate closed_ flag: close() sets fd_ = -1 after ::close(), and the diff --git a/esphome/components/socket/headers.h b/esphome/components/socket/headers.h index 528d201799..1d50555cd1 100644 --- a/esphome/components/socket/headers.h +++ b/esphome/components/socket/headers.h @@ -20,6 +20,16 @@ #define IPPROTO_IP 0 #define IPPROTO_TCP 6 +#define IPPROTO_UDP 17 + +#define IP_ADD_MEMBERSHIP 3 +#define IP_DROP_MEMBERSHIP 4 + +// NOLINTNEXTLINE(readability-identifier-naming) +struct ip_mreq { + struct in_addr imr_multiaddr; + struct in_addr imr_interface; +}; #if LWIP_IPV6 #define AF_INET6 10 @@ -205,6 +215,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..d5da8745b9 --- /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 { + +ESPHOME_LOG_TAG(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_common_impl.cpp b/esphome/components/socket/lwip_raw_common_impl.cpp new file mode 100644 index 0000000000..4ec0a37355 --- /dev/null +++ b/esphome/components/socket/lwip_raw_common_impl.cpp @@ -0,0 +1,116 @@ +#include "lwip_raw_common_impl.h" +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +namespace esphome::socket { + +int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen) { + if (family == AF_INET) { + if (*addrlen < sizeof(struct sockaddr_in)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin_family = AF_INET; + *addrlen = addr->sin_len = sizeof(struct sockaddr_in); + addr->sin_port = htons(port_host); + inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); + return 0; + } +#if LWIP_IPV6 + if (family == AF_INET6) { + if (*addrlen < sizeof(struct sockaddr_in6)) { + errno = EINVAL; + return -1; + } + auto *addr = reinterpret_cast(name); + addr->sin6_family = AF_INET6; + *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); + addr->sin6_port = htons(port_host); + // AF_INET6 sockets may receive IPv4 packets; convert to IPv4-mapped IPv6. + if (IP_IS_V4(ip)) { + ip_addr_t mapped; + ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); + } else { + inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); + } + return 0; + } +#endif + errno = EAFNOSUPPORT; + return -1; +} + +bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port) { + // headers.h defines sockaddr and sockaddr_in with the same size, so this covers AF_INET + if (addrlen < sizeof(struct sockaddr)) + return false; + // Zero the whole struct — the IPv6 zone byte would otherwise be stack garbage + memset(ip, 0, sizeof(*ip)); + if (addr->sa_family == AF_INET) { + auto *addr4 = reinterpret_cast(addr); + *port = ntohs(addr4->sin_port); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V4); + ip_2_ip4(ip)->addr = addr4->sin_addr.s_addr; + return true; + } +#if LWIP_IPV6 + if (addr->sa_family == AF_INET6) { + if (addrlen < sizeof(sockaddr_in6)) + return false; + auto *addr6 = reinterpret_cast(addr); + *port = ntohs(addr6->sin6_port); + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_V6); + memcpy(&ip_2_ip6(ip)->addr, &addr6->sin6_addr.un.u8_addr, 16); + // Unmap ::ffff:a.b.c.d so replies to recvfrom addresses route as IPv4 + 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); + } + return true; + } +#endif + return false; +} + +bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, + uint16_t *port) { + if (!sockaddr_to_lwip(addr, addrlen, ip, port)) + return false; +#if LWIP_IPV6 + // Only promote wildcard binds — a specific address must keep filtering + if (family == AF_INET6 && ip_addr_isany_val(*ip)) + IP_SET_TYPE_VAL(*ip, IPADDR_TYPE_ANY); +#endif + return true; +} + +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; + } +} + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_common_impl.h b/esphome/components/socket/lwip_raw_common_impl.h new file mode 100644 index 0000000000..49e3a23f23 --- /dev/null +++ b/esphome/components/socket/lwip_raw_common_impl.h @@ -0,0 +1,53 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include "headers.h" +#include "lwip/ip.h" + +namespace esphome::socket { + +// ---- LWIP thread safety ---- +// +// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. +// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority +// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk +// async_context_threadsafe_background.c). They can preempt main-loop code at any point. +// +// Without locking, this causes race conditions between recv_fn and read() on the +// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing +// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. +// +// On ESP8266, lwip callbacks run from the SYS context which cooperates with user +// code (CONT context) — they never preempt each other, so no locking is needed. +// +// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). +// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end +// (Ethernet). On ESP8266, it's a no-op. +// +// Each .cpp file that needs locking defines its own LWIP_LOCK() macro: +// #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard +// This is a per-TU convenience macro, not defined here to avoid macro leaking. + +/// Convert lwip ip_addr_t + host-order port to sockaddr, based on the socket's address family. +/// TCP callers pass ntohs(pcb port) to preserve historical getpeername/getsockname output. +int lwip_ip_to_sockaddr(sa_family_t family, const ip_addr_t *ip, uint16_t port_host, struct sockaddr *name, + socklen_t *addrlen); + +/// Convert sockaddr to lwip ip_addr_t and host-order port. +/// For IPv6, sets type to IPADDR_TYPE_V6 — correct for sendto destinations. +/// Bind paths must use sockaddr_to_lwip_bind() instead. +bool sockaddr_to_lwip(const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, uint16_t *port); + +/// sockaddr_to_lwip variant for bind: promotes AF_INET6 sockets to +/// IPADDR_TYPE_ANY so they accept both IPv4 and IPv6 (dual-stack). +bool sockaddr_to_lwip_bind(sa_family_t family, const struct sockaddr *addr, socklen_t addrlen, ip_addr_t *ip, + uint16_t *port); + +/// errno for a failed lwip call +int lwip_err_to_errno(err_t err); + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 8d00dbede2..77ff0f4735 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -10,6 +10,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/wake.h" #include "esphome/core/log.h" +#include "lwip_raw_common_impl.h" #ifdef USE_OTA_PLATFORM_ESPHOME extern "C" void esphome_wake_ota_component_any_context(); @@ -24,30 +25,20 @@ extern "C" void esphome_wake_ota_component_any_context(); namespace esphome::socket { -// ---- LWIP thread safety ---- -// -// On RP2040 (Pico W), arduino-pico sets PICO_CYW43_ARCH_THREADSAFE_BACKGROUND=1. -// This means lwip callbacks (recv_fn, accept_fn, err_fn) run from a low-priority -// user IRQ context, not the main loop (see low_priority_irq_handler() in pico-sdk -// async_context_threadsafe_background.c). They can preempt main-loop code at any point. -// -// Without locking, this causes race conditions between recv_fn and read() on the -// shared rx_buf_ pbuf chain — recv_fn calls pbuf_cat() while read() is freeing -// nodes, leading to use-after-free and infinite-loop crashes. See esphome#10681. -// -// On ESP8266, lwip callbacks run from the SYS context which cooperates with user -// code (CONT context) — they never preempt each other, so no locking is needed. -// -// esphome::LwIPLock is the platform-provided RAII guard (see helpers.h/helpers.cpp). -// On RP2040, it acquires cyw43_arch_lwip_begin/end (WiFi) or ethernet_arch_lwip_begin/end -// (Ethernet). On ESP8266, it's a no-op. +// LWIP thread safety — see lwip_raw_common_impl.h for full explanation. +// esphome::LwIPLock is the platform-provided RAII guard. +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. #define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT -static const char *const TAG = "socket"; +ESPHOME_LOG_TAG(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 // set to 1 to enable verbose lwip logging @@ -62,8 +53,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 +67,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 +91,32 @@ LWIPRawCommon::~LWIPRawCommon() { } } +bool LWIPRawCommon::sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port, + bool for_bind) const { + if (name != nullptr && name->sa_family == this->family_ && + (for_bind ? sockaddr_to_lwip_bind(this->family_, name, addrlen, ip, port) + : sockaddr_to_lwip(name, addrlen, ip, port))) + return true; + errno = EINVAL; + return false; +} + 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, true)) { 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 +133,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 +160,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; @@ -349,43 +304,8 @@ int LWIPRawCommon::setsockopt(int level, int optname, const void *optval, sockle } int LWIPRawCommon::ip2sockaddr_(ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { - if (this->family_ == AF_INET) { - if (*addrlen < sizeof(struct sockaddr_in)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in *addr = reinterpret_cast(name); - addr->sin_family = AF_INET; - *addrlen = addr->sin_len = sizeof(struct sockaddr_in); - addr->sin_port = port; - inet_addr_from_ip4addr(&addr->sin_addr, ip_2_ip4(ip)); - return 0; - } -#if LWIP_IPV6 - else if (this->family_ == AF_INET6) { - if (*addrlen < sizeof(struct sockaddr_in6)) { - errno = EINVAL; - return -1; - } - - struct sockaddr_in6 *addr = reinterpret_cast(name); - addr->sin6_family = AF_INET6; - *addrlen = addr->sin6_len = sizeof(struct sockaddr_in6); - addr->sin6_port = port; - - // AF_INET6 sockets are bound to IPv4 as well, so we may encounter IPv4 addresses that must be converted to IPv6. - if (IP_IS_V4(ip)) { - ip_addr_t mapped; - ip4_2_ipv4_mapped_ipv6(ip_2_ip6(&mapped), ip_2_ip4(ip)); - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(&mapped)); - } else { - inet6_addr_from_ip6addr(&addr->sin6_addr, ip_2_ip6(ip)); - } - return 0; - } -#endif - return -1; + // lwip pcb ports are host order; ntohs preserves historical byte-swapped sin_port output + return lwip_ip_to_sockaddr(this->family_, ip, ntohs(port), name, addrlen); } // ---- LWIPRawImpl methods ---- @@ -425,7 +345,72 @@ 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; + // Concrete type for tcp_connect; a remembered IPv4 peer arrives v4-mapped and is unmapped + if (!this->sockaddr2ip_(addr, addrlen, &ip, &port, false)) { + return -1; + } + 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 +525,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 +618,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; } @@ -887,11 +867,11 @@ err_t LWIPRawListenImpl::accept_fn_(struct tcp_pcb *newpcb, err_t err) { return ERR_OK; } -// ---- Factory functions ---- +// ---- TCP Factory functions ---- std::unique_ptr socket(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } @@ -911,7 +891,7 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol std::unique_ptr socket_listen(int domain, int type, int protocol) { if (type != SOCK_STREAM) { - ESP_LOGE(TAG, "UDP sockets not supported on this platform, use WiFiUDP"); + ESP_LOGE(TAG, "Use socket_udp() for UDP sockets on this platform"); errno = EPROTOTYPE; return nullptr; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index 917b5b2f7a..69158cc8fe 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -50,6 +50,9 @@ 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. + /// for_bind keeps an IPv6 wildcard dual stack, otherwise the type is concrete. + bool sockaddr2ip_(const struct sockaddr *name, socklen_t addrlen, ip_addr_t *ip, uint16_t *port, bool for_bind) const; // Member ordering optimized to minimize padding on 32-bit systems struct tcp_pcb *pcb_; @@ -58,7 +61,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 +93,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 +136,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 +154,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_raw_udp_impl.cpp b/esphome/components/socket/lwip_raw_udp_impl.cpp new file mode 100644 index 0000000000..dceba802ae --- /dev/null +++ b/esphome/components/socket/lwip_raw_udp_impl.cpp @@ -0,0 +1,333 @@ +#include "socket.h" +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include "esphome/core/wake.h" +#include "lwip_raw_common_impl.h" + +#include "lwip/igmp.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" + +namespace esphome::socket { + +// LWIP thread safety — see lwip_raw_common_impl.h for full explanation. +// esphome::LwIPLock is the platform-provided RAII guard. +// On RP2040, it acquires cyw43_arch_lwip_begin/end. On ESP8266, it's a no-op. +#define LWIP_LOCK() esphome::LwIPLock lwip_lock_guard // NOLINT + +// ---- LWIPRawUDPSendImpl (send-only) methods ---- + +LWIPRawUDPSendImpl::~LWIPRawUDPSendImpl() { + // Guard avoids acquiring the lwip lock when already closed + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPSendImpl::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; + uint16_t port; + if (!sockaddr_to_lwip_bind(this->family_, name, addrlen, &ip, &port)) { + errno = EINVAL; + return -1; + } + err_t err = udp_bind(this->pcb_, &ip, port); + if (err != ERR_OK) { + errno = lwip_err_to_errno(err); + return -1; + } + return 0; +} + +int LWIPRawUDPSendImpl::close() { + LWIP_LOCK(); + return this->close_internal_locked_(); +} + +int LWIPRawUDPSendImpl::close_internal_locked_() { + // Caller must hold LWIP_LOCK + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + udp_remove(this->pcb_); + this->pcb_ = nullptr; + return 0; +} + +int LWIPRawUDPSendImpl::ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen) { + // UDP recv callback provides port in host byte order + return lwip_ip_to_sockaddr(this->family_, ip, port, name, addrlen); +} + +ssize_t LWIPRawUDPSendImpl::sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, + socklen_t addrlen) { + (void) flags; // Flags (MSG_DONTWAIT, etc.) are ignored; raw lwip is always non-blocking + if (buf == nullptr || dest_addr == nullptr) { + errno = EINVAL; + return -1; + } + + // pbuf_alloc takes u16_t length; reject oversized packets + if (len > UINT16_MAX) { + errno = EMSGSIZE; + return -1; + } + + ip_addr_t dst_ip; + uint16_t dst_port; + if (!sockaddr_to_lwip(dest_addr, addrlen, &dst_ip, &dst_port)) { + errno = EINVAL; + return -1; + } + + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + + // Allocate pbuf and copy data + struct pbuf *pb = pbuf_alloc(PBUF_TRANSPORT, (uint16_t) len, PBUF_RAM); + if (pb == nullptr) { + errno = ENOMEM; + return -1; + } + memcpy(pb->payload, buf, len); + + err_t err = udp_sendto(this->pcb_, pb, &dst_ip, dst_port); + pbuf_free(pb); + + if (err != ERR_OK) { + errno = lwip_err_to_errno(err); + return -1; + } + return (ssize_t) len; +} + +int LWIPRawUDPSendImpl::setsockopt(int level, int optname, const void *optval, socklen_t optlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + // Effective only where lwip is built with SO_REUSE=1 (ESP8266 yes, RP2040 currently no) + if (*reinterpret_cast(optval)) { + ip_set_option(this->pcb_, SOF_REUSEADDR); + } else { + ip_reset_option(this->pcb_, SOF_REUSEADDR); + } + return 0; + } + if (level == SOL_SOCKET && optname == SO_BROADCAST) { + if (optval == nullptr || optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + int val = *reinterpret_cast(optval); + if (val) { + ip_set_option(this->pcb_, SOF_BROADCAST); + } else { + ip_reset_option(this->pcb_, SOF_BROADCAST); + } + return 0; + } + if (level == IPPROTO_IP && (optname == IP_ADD_MEMBERSHIP || optname == IP_DROP_MEMBERSHIP)) { + if (optval == nullptr || optlen < sizeof(struct ip_mreq)) { + errno = EINVAL; + return -1; + } + auto *mreq = reinterpret_cast(optval); + ip4_addr_t multiaddr{mreq->imr_multiaddr.s_addr}; + ip4_addr_t ifaddr{mreq->imr_interface.s_addr}; + err_t err = + optname == IP_ADD_MEMBERSHIP ? igmp_joingroup(&ifaddr, &multiaddr) : igmp_leavegroup(&ifaddr, &multiaddr); + if (err != ERR_OK) { + errno = EIO; + return -1; + } + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPSendImpl::getsockopt(int level, int optname, void *optval, socklen_t *optlen) { + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (level == SOL_SOCKET && optname == SO_REUSEADDR) { + if (optval == nullptr || optlen == nullptr || *optlen < sizeof(int)) { + errno = EINVAL; + return -1; + } + *reinterpret_cast(optval) = ip_get_option(this->pcb_, SOF_REUSEADDR) ? 1 : 0; + *optlen = sizeof(int); + return 0; + } + errno = ENOPROTOOPT; + return -1; +} + +int LWIPRawUDPSendImpl::setblocking(bool blocking) { + if (blocking) { + // blocking operation not supported on raw lwip + errno = EINVAL; + return -1; + } + return 0; +} + +// ---- LWIPRawUDPImpl methods ---- + +LWIPRawUDPImpl::LWIPRawUDPImpl(sa_family_t family, struct udp_pcb *pcb) : LWIPRawUDPSendImpl(family, pcb) { + // Registered here (not in bind) so unbound client sockets can receive replies + udp_recv(this->pcb_, LWIPRawUDPImpl::s_recv_fn, this); +} + +LWIPRawUDPImpl::~LWIPRawUDPImpl() { + // Flush rx queue and unregister callback before base destructor removes pcb + if (this->pcb_ != nullptr) + this->close(); +} + +int LWIPRawUDPImpl::close() { + LWIP_LOCK(); + // Unregister recv callback before removing pcb + if (this->pcb_ != nullptr) { + udp_recv(this->pcb_, nullptr, nullptr); + } + // Flush queued rx packets; slots within rx_count_ always hold a live pbuf + for (; this->rx_count_ > 0; this->rx_count_--) { + pbuf_free(this->rx_queue_[this->rx_read_idx_].pb); + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + } + // close_internal_locked_() returns EBADF if already closed, which is fine from destructor + return this->close_internal_locked_(); +} + +ssize_t LWIPRawUDPImpl::read(void *buf, size_t len) { return this->recvfrom(buf, len, nullptr, nullptr); } + +ssize_t LWIPRawUDPImpl::recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen) { + if (buf == nullptr && len > 0) { + errno = EINVAL; + return -1; + } + LWIP_LOCK(); + if (this->pcb_ == nullptr) { + errno = EBADF; + return -1; + } + if (this->rx_count_ == 0) { + errno = EWOULDBLOCK; + return -1; + } + + auto &pkt = this->rx_queue_[this->rx_read_idx_]; + // On address conversion failure, still consume the packet — the failure is + // deterministic (family_ and *addrlen), so keeping it would wedge the queue + ssize_t ret = -1; + if (src_addr == nullptr || addrlen == nullptr || + this->ip2sockaddr_(&pkt.src_addr, pkt.src_port, src_addr, addrlen) == 0) { + ret = (ssize_t) std::min(len, (size_t) pkt.pb->tot_len); + pbuf_copy_partial(pkt.pb, buf, ret, 0); + } + pbuf_free(pkt.pb); + this->rx_read_idx_ = (this->rx_read_idx_ + 1) & UDP_RX_MASK; + this->rx_count_--; + return ret; +} + +void LWIPRawUDPImpl::s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { + auto *self = reinterpret_cast(arg); + self->recv_fn_(p, addr, port); +} + +// LWIP CALLBACK — runs from IRQ context on RP2040 (low-priority user IRQ). +// No heap allocation allowed — malloc is not IRQ-safe (see #14687). +// No LWIP_LOCK() needed — lwip core already holds the async_context lock. +void LWIPRawUDPImpl::recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port) { + if (p == nullptr) + return; + + // Check if queue is full + if (this->rx_count_ >= UDP_RX_QUEUE_SIZE) { + // Drop packet — queue full. Can't log from IRQ context, so count it + // (saturating) for consumers to surface via get_rx_dropped(). + if (this->rx_dropped_ != UINT16_MAX) + this->rx_dropped_++; + pbuf_free(p); + return; + } + + // Enqueue the packet + uint8_t write_idx = (this->rx_read_idx_ + this->rx_count_) & UDP_RX_MASK; + auto &slot = this->rx_queue_[write_idx]; + slot.pb = p; + slot.src_addr = *addr; + slot.src_port = port; + this->rx_count_++; + + esphome::wake_loop_any_context(); +} + +// ---- UDP Factory functions ---- + +static struct udp_pcb *new_udp_pcb(int domain) { +#if LWIP_IPV6 + return udp_new_ip_type(domain == AF_INET6 ? IPADDR_TYPE_ANY : IPADDR_TYPE_V4); +#else + return udp_new(); +#endif +} + +std::unique_ptr socket_udp_send(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); + auto *pcb = new_udp_pcb(domain); + if (pcb == nullptr) { + errno = ENOMEM; + return nullptr; + } + return make_unique((sa_family_t) domain, pcb); +} + +std::unique_ptr socket_udp(int domain, int protocol) { + (void) protocol; // Raw lwip UDP ignores protocol; kept for API compatibility + LWIP_LOCK(); + auto *pcb = new_udp_pcb(domain); + if (pcb == nullptr) { + errno = ENOMEM; + return nullptr; + } + // Ctor registers the recv callback under the lock held here + return make_unique((sa_family_t) domain, pcb); +} + +#undef LWIP_LOCK + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP diff --git a/esphome/components/socket/lwip_raw_udp_impl.h b/esphome/components/socket/lwip_raw_udp_impl.h new file mode 100644 index 0000000000..268515106f --- /dev/null +++ b/esphome/components/socket/lwip_raw_udp_impl.h @@ -0,0 +1,113 @@ +#pragma once +#include "esphome/core/defines.h" + +#ifdef USE_SOCKET_IMPL_LWIP_TCP + +#include +#include + +#include "headers.h" +#include "lwip/ip.h" +#include "lwip/udp.h" + +namespace esphome::socket { + +/// Send-only UDP socket implementation for LWIP raw API. +/// Non-virtual, concrete type. Uses lwip/udp.h raw API. +/// No receive capability — use LWIPRawUDPImpl for sockets that need to receive. +class LWIPRawUDPSendImpl { + public: + /// The pcb is allocated by the factory (like the TCP impl); never null here. + LWIPRawUDPSendImpl(sa_family_t family, struct udp_pcb *pcb) : pcb_(pcb), family_(family) {} + ~LWIPRawUDPSendImpl(); + LWIPRawUDPSendImpl(const LWIPRawUDPSendImpl &) = delete; + LWIPRawUDPSendImpl &operator=(const LWIPRawUDPSendImpl &) = delete; + + int bind(const struct sockaddr *name, socklen_t addrlen); + int close(); + + /// Send a UDP packet to the specified destination. + ssize_t sendto(const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen); + + int setsockopt(int level, int optname, const void *optval, socklen_t optlen); + int getsockopt(int level, int optname, void *optval, socklen_t *optlen); + + int setblocking(bool blocking); + + bool ready() const { return false; } + int get_fd() const { return -1; } + + protected: + /// Convert lwip ip_addr_t and port to sockaddr. + int ip2sockaddr_(const ip_addr_t *ip, uint16_t port, struct sockaddr *name, socklen_t *addrlen); + + /// Shared close logic — removes the udp pcb. Caller must hold LWIP_LOCK. + int close_internal_locked_(); + + struct udp_pcb *pcb_; + sa_family_t family_; +}; + +/// UDP socket with receive support for LWIP raw API. +/// Extends LWIPRawUDPSendImpl with a fixed-size ring buffer for incoming packets. +/// Inheritance is private (base dtor is non-virtual; converting to a base +/// pointer would leak queued pbufs on destruction). +class LWIPRawUDPImpl : private LWIPRawUDPSendImpl { + public: + /// Caller (the factory) must hold the lwip lock; registers the recv callback. + LWIPRawUDPImpl(sa_family_t family, struct udp_pcb *pcb); + ~LWIPRawUDPImpl(); + + using LWIPRawUDPSendImpl::bind; + using LWIPRawUDPSendImpl::get_fd; + using LWIPRawUDPSendImpl::getsockopt; + using LWIPRawUDPSendImpl::sendto; + using LWIPRawUDPSendImpl::setblocking; + using LWIPRawUDPSendImpl::setsockopt; + + /// Close the socket, flushing any queued rx packets first. + int close(); + + /// Read the next queued packet, discarding source address info. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. + ssize_t read(void *buf, size_t len); + /// Read the next queued packet and return the source address. + /// If buf is smaller than the packet, data is silently truncated (returns bytes copied). + /// Note: unlike POSIX MSG_TRUNC, this does not return the original packet length on truncation. + ssize_t recvfrom(void *buf, size_t len, struct sockaddr *src_addr, socklen_t *addrlen); + + /// Returns true if there are packets available to read. + /// Intentionally unlocked — same rationale as LWIPRawImpl::ready(). + bool ready() const { return this->rx_count_ > 0; } + + /// Number of packets dropped because the rx queue was full (saturating). + uint16_t get_rx_dropped() const { return this->rx_dropped_; } + + protected: + static void s_recv_fn(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port); + void recv_fn_(struct pbuf *p, const ip_addr_t *addr, u16_t port); + + /// Ring buffer for received UDP packets. + /// Both producer (recv callback) and consumer (main loop) are serialized by the + /// lwip lock — the callback runs under lwip core lock, and consumer methods hold + /// LWIP_LOCK(). All 4 slots are usable (no wasted slot for full/empty distinction). + /// No heap allocation in the recv callback — packets are dropped if the queue is full. + static constexpr uint8_t UDP_RX_QUEUE_SIZE = 4; + static constexpr uint8_t UDP_RX_MASK = UDP_RX_QUEUE_SIZE - 1; + static_assert((UDP_RX_QUEUE_SIZE & UDP_RX_MASK) == 0, "UDP_RX_QUEUE_SIZE must be power of 2"); + // Fields are written by recv_fn_ before rx_count_ makes a slot visible + struct UDPRxPacket { + ip_addr_t src_addr; + struct pbuf *pb; + uint16_t src_port; + }; + std::array rx_queue_{}; + uint16_t rx_dropped_{0}; + uint8_t rx_read_idx_{0}; + uint8_t rx_count_{0}; +}; + +} // namespace esphome::socket + +#endif // USE_SOCKET_IMPL_LWIP_TCP 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/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index 7f3b706cd8..9cba164716 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -84,6 +84,9 @@ class LwIPSocketImpl { int get_fd() const { return this->fd_; } + /// UDP rx drop counter parity with LWIPRawUDPImpl; drops are not counted here. + uint16_t get_rx_dropped() const { return 0; } + protected: // fd_ < 0 means "not open" — used both pre-open (initial state) and post-close. This // replaces a separate closed_ flag: close() sets fd_ = -1 after lwip_close(), and the diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 567937a4db..630d68b562 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -2,12 +2,26 @@ #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" +#include "esphome/core/helpers.h" #ifdef USE_HOST #include "esphome/core/wake.h" #endif +#if defined(USE_HOST) +#include +#include +#elif defined(USE_ZEPHYR) +#ifndef CLANG_TIDY // NCS 2.9.2 net_if.h has a BUILD_ASSERT clang rejects; gone in NCS 3.4 +#include +#endif +#else +#include "lwip/netif.h" +#endif namespace esphome::socket { @@ -75,6 +89,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 +118,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()); } @@ -116,25 +130,219 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s return 0; } -std::unique_ptr socket_ip(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket(AF_INET6, type, protocol); +std::unique_ptr socket_ip(int type, int protocol) { return socket(IP_DOMAIN, type, protocol); } + +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +// Callback must be a temporary lambda +template static bool foreach_eligible_ipv6_if(F &&callback) { +#if defined(USE_HOST) + struct ifaddrs *ifaddr; + if (getifaddrs(&ifaddr) != 0) { + return false; + } + bool found_any = false; + for (struct ifaddrs *ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == nullptr || ifa->ifa_addr->sa_family != AF_INET6) { + continue; + } + if ((ifa->ifa_flags & IFF_LOOPBACK) || !(ifa->ifa_flags & IFF_UP) || !(ifa->ifa_flags & IFF_MULTICAST)) { + continue; + } + unsigned int idx = if_nametoindex(ifa->ifa_name); + if (idx == 0) { + continue; + } + found_any = true; + if (callback(idx)) { + break; + } + } + freeifaddrs(ifaddr); + if (!found_any) { + errno = EADDRNOTAVAIL; + } + return found_any; +#elif defined(USE_ZEPHYR) && defined(CLANG_TIDY) + (void) callback; + return false; +#elif defined(USE_ZEPHYR) + // Zephyr has no getifaddrs()/ifaddrs.h; enumerate interfaces via the native net_if API. + // NET_IF_IPV6_NO_MLD is the direct analog of the LwIP branch's NETIF_FLAG_MLD6 check below. + struct EligibleIfContext { + F *callback; + bool found_any = false; + bool done = false; + }; + EligibleIfContext ctx{&callback}; + net_if_foreach( + [](struct net_if *iface, void *user_data) { + auto *ctx = static_cast(user_data); + if (ctx->done) { + return; + } + if (!net_if_flag_is_set(iface, NET_IF_UP) || !net_if_flag_is_set(iface, NET_IF_IPV6) || + net_if_flag_is_set(iface, NET_IF_IPV6_NO_MLD)) { + return; + } + int idx = net_if_get_by_iface(iface); + if (idx <= 0) { + return; + } + ctx->found_any = true; + if ((*ctx->callback)(static_cast(idx))) { + ctx->done = true; + } + }, + &ctx); + if (!ctx.found_any) { + errno = EADDRNOTAVAIL; + } + return ctx.found_any; #else - return socket(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ + // The netif list needs the core lock, but the callback's setsockopt() takes that lock itself, so the + // indices are collected first. One that goes away before the callback runs is rejected by setsockopt(). + // Devices have only a few interfaces, so the cap drops nothing in practice. + static constexpr size_t MAX_IFS = 8; + StaticVector indices; + { + LwIPLock lock; + struct netif *netif; + NETIF_FOREACH(netif) { + if (indices.size() == MAX_IFS) { + break; + } + if (netif->name[0] == 'l' && netif->name[1] == 'o') { + continue; + } + if (!(netif->flags & NETIF_FLAG_UP) || !(netif->flags & NETIF_FLAG_MLD6)) { + continue; + } + indices.push_back(netif_get_index(netif)); + } + } + for (uint8_t index : indices) { + if (callback(index)) { + break; + } + } + if (indices.empty()) { + errno = EADDRNOTAVAIL; + } + return !indices.empty(); +#endif +} +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + +bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out) { + if (strchr(ip_address, ':') == nullptr) { + struct in_addr ipv4mc {}; +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET, ip_address, &ipv4mc) != 1) { +#else + if (inet_aton(ip_address, &ipv4mc) == 0) { +#endif + errno = EINVAL; + return false; + } +#ifdef USE_ZEPHYR + // Zephyr's setsockopt(IP_ADD_MEMBERSHIP) requires exactly sizeof(struct ip_mreqn); it has + // no plain "struct ip_mreq" at all (unlike POSIX/lwIP). + struct ip_mreqn imreq {}; + imreq.imr_address.s_addr = ESPHOME_INADDR_ANY; + imreq.imr_ifindex = 0; +#else + struct ip_mreq imreq {}; + imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; +#endif + imreq.imr_multiaddr = ipv4mc; + if (sock->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)) < 0) { + return false; + } + if (if_index_out != nullptr) { + *if_index_out = 0; + } + return true; + } +#ifdef USE_SOCKET_IPV6 + struct ipv6_mreq imreq6 {}; +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS +#ifdef USE_ZEPHYR + if (zsock_inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#else + if (inet_pton(AF_INET6, ip_address, &imreq6.ipv6mr_multiaddr) != 1) { +#endif + errno = EINVAL; + return false; + } +#else + ip6_addr_t ip6; + if (!inet6_aton(ip_address, &ip6)) { + errno = EINVAL; + return false; + } + memcpy(imreq6.ipv6mr_multiaddr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); +#endif + // POSIX: interface=0 fails for link-local multicast (ff02::) and may select loopback for + // other scopes. LwIP: NETIF_NO_INDEX=0 is always rejected. + bool joined = false; + foreach_eligible_ipv6_if([&](unsigned int idx) { +#ifdef USE_ZEPHYR + // This Zephyr fork's struct ipv6_mreq has no "ipv6mr_interface" (POSIX name) and no + // IPV6_JOIN_GROUP alias -- only the RFC 3678 legacy ipv6mr_ifindex/IPV6_ADD_MEMBERSHIP names. + imreq6.ipv6mr_ifindex = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_ADD_MEMBERSHIP, &imreq6, sizeof(imreq6)) == 0) { +#else + imreq6.ipv6mr_interface = idx; + if (sock->setsockopt(IPPROTO_IPV6, IPV6_JOIN_GROUP, &imreq6, sizeof(imreq6)) == 0) { +#endif + joined = true; + return true; + } + return false; + }); + if (!joined) { + return false; + } + if (if_index_out != nullptr) { +#ifdef USE_ZEPHYR + *if_index_out = imreq6.ipv6mr_ifindex; +#else + *if_index_out = imreq6.ipv6mr_interface; +#endif + } + return true; +#else + errno = EINVAL; + return false; +#endif // USE_SOCKET_IPV6 } -#ifdef USE_SOCKET_IMPL_LWIP_TCP -// LWIP_TCP has separate Socket/ListenSocket types — needs out-of-line factory. -// BSD and LWIP_SOCKETS define this inline in socket.h. -std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #if USE_NETWORK_IPV6 - return socket_listen_loop_monitored(AF_INET6, type, protocol); +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in) { +#if defined(USE_ZEPHYR) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) + // Upstream lwIP (Zephyr's fork, LibreTiny) has no IPV6_MULTICAST_IF; outgoing + // multicast follows the default netif. Zephyr gains ZSOCK_IPV6_MULTICAST_IF in NCS 3.4.0. + (void) sock; + (void) if_index_in; + return true; #else - return socket_listen_loop_monitored(AF_INET, type, protocol); -#endif /* USE_NETWORK_IPV6 */ -} + uint32_t ifindex = if_index_in; + if (ifindex == 0) { + foreach_eligible_ipv6_if([&](unsigned int idx) { + ifindex = idx; + return true; + }); + if (ifindex == 0) { + errno = EADDRNOTAVAIL; + return false; + } + } + return sock->setsockopt(IPPROTO_IPV6, IPV6_MULTICAST_IF, &ifindex, sizeof(ifindex)) == 0; #endif +} +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port) { #if USE_NETWORK_IPV6 @@ -165,7 +373,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); @@ -195,6 +406,47 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ 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..e8f9208774 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -20,6 +20,7 @@ #include "lwip_sockets_impl.h" #elif defined(USE_SOCKET_IMPL_LWIP_TCP) #include "lwip_raw_tcp_impl.h" +#include "lwip_raw_udp_impl.h" #endif namespace esphome::socket { @@ -27,17 +28,37 @@ namespace esphome::socket { // Type aliases — only one implementation is active per build. // Socket is the concrete type for connected sockets. // ListenSocket is the concrete type for listening/server sockets. -// On BSD and LWIP_SOCKETS, both aliases resolve to the same type. +// UDPSocket is the concrete type for UDP sockets (send + receive). +// UDPSendSocket is the concrete type for send-only UDP sockets. +// On BSD and LWIP_SOCKETS, all aliases resolve to the same type. // On LWIP_TCP, they are different types (no virtual dispatch between them). #ifdef USE_SOCKET_IMPL_BSD_SOCKETS using Socket = BSDSocketImpl; using ListenSocket = BSDSocketImpl; +using UDPSendSocket = BSDSocketImpl; +using UDPSocket = BSDSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_SOCKETS) using Socket = LwIPSocketImpl; using ListenSocket = LwIPSocketImpl; +using UDPSendSocket = LwIPSocketImpl; +using UDPSocket = LwIPSocketImpl; #elif defined(USE_SOCKET_IMPL_LWIP_TCP) using Socket = LWIPRawImpl; using ListenSocket = LWIPRawListenImpl; +using UDPSendSocket = LWIPRawUDPSendImpl; +using UDPSocket = LWIPRawUDPImpl; +#endif + +// Domain used by the socket_ip_* helpers: newest available IP domain. +#if USE_NETWORK_IPV6 +inline constexpr int IP_DOMAIN = AF_INET6; +#else +inline constexpr int IP_DOMAIN = AF_INET; +#endif + +// IPv6 sockets with multicast membership; the raw lwIP implementation has no IPv6 membership support. +#if USE_NETWORK_IPV6 && (defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)) +#define USE_SOCKET_IPV6 #endif #ifdef USE_LWIP_FAST_SELECT @@ -94,6 +115,7 @@ inline bool Socket::ready() const { /// Create a socket of the given domain, type and protocol. std::unique_ptr socket(int domain, int type, int protocol); /// Create a socket in the newest available IP domain (IPv6 or IPv4) of the given type and protocol. +/// Not registered with the main loop's select() - use socket_ip_loop_monitored() if you need that. std::unique_ptr socket_ip(int type, int protocol); /// Create a socket and monitor it for data in the main loop. @@ -104,6 +126,36 @@ std::unique_ptr socket_ip(int type, int protocol); /// File descriptors >= FD_SETSIZE will not be monitored and will log an error. std::unique_ptr socket_loop_monitored(int domain, int type, int protocol); +/// Create a send-only UDP socket (socket_udp_send), a UDP socket with receive +/// support (socket_udp), or a UDP socket monitored for data in the main loop +/// (socket_udp_loop_monitored). +#ifdef USE_SOCKET_IMPL_LWIP_TCP +std::unique_ptr socket_udp_send(int domain, int protocol); +std::unique_ptr socket_udp(int domain, int protocol); +// Wake is built into the recv callback, so monitoring needs nothing extra. +inline std::unique_ptr socket_udp_loop_monitored(int domain, int protocol) { + return socket_udp(domain, protocol); +} +#else +inline std::unique_ptr socket_udp_send(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +inline std::unique_ptr socket_udp(int domain, int protocol) { + return esphome::socket::socket(domain, SOCK_DGRAM, protocol); +} +// Registers the socket with the Application's select() loop. +inline std::unique_ptr socket_udp_loop_monitored(int domain, int protocol) { + return socket_loop_monitored(domain, SOCK_DGRAM, protocol); +} +#endif + +/// socket_udp* variants using the newest available IP domain. +inline std::unique_ptr socket_ip_udp_send(int protocol) { return socket_udp_send(IP_DOMAIN, protocol); } +inline std::unique_ptr socket_ip_udp(int protocol) { return socket_udp(IP_DOMAIN, protocol); } +inline std::unique_ptr socket_ip_udp_loop_monitored(int protocol) { + return socket_udp_loop_monitored(IP_DOMAIN, protocol); +} + /// Create a listening socket of the given domain, type and protocol. /// Create a listening socket and monitor it for data in the main loop. /// Create a listening socket in the newest available IP domain and monitor it. @@ -111,7 +163,6 @@ std::unique_ptr socket_loop_monitored(int domain, int type, int protocol // LWIP_TCP has separate Socket/ListenSocket types — needs distinct factory functions. std::unique_ptr socket_listen(int domain, int type, int protocol); std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol); -std::unique_ptr socket_ip_loop_monitored(int type, int protocol); #else // BSD and LWIP_SOCKETS: Socket == ListenSocket, so listen variants just delegate. inline std::unique_ptr socket_listen(int domain, int type, int protocol) { @@ -120,14 +171,10 @@ inline std::unique_ptr socket_listen(int domain, int type, int pro inline std::unique_ptr socket_listen_loop_monitored(int domain, int type, int protocol) { return socket_loop_monitored(domain, type, protocol); } +#endif inline std::unique_ptr socket_ip_loop_monitored(int type, int protocol) { -#if USE_NETWORK_IPV6 - return socket_loop_monitored(AF_INET6, type, protocol); -#else - return socket_loop_monitored(AF_INET, type, protocol); -#endif + return socket_listen_loop_monitored(IP_DOMAIN, type, protocol); } -#endif /// Set a sockaddr to the specified address and port for the IP version used by socket_ip(). /// @param addr Destination sockaddr structure @@ -137,6 +184,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 +196,35 @@ 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); +/// Join a multicast group on the given socket. +/// @param sock The socket to join on +/// @param ip_address Null-terminated multicast address string (IPv4, or IPv6 except on LWIP_TCP) +/// @param if_index_out If non-null, receives the interface index used (IPv4 always writes 0). For +/// link-local IPv6 groups, the caller must also copy this into sin6_scope_id +/// before bind() -- POSIX requires it, LwIP ignores it. +/// @return true on success, false on failure (errno set) +bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out = nullptr); + +#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) +#if USE_NETWORK_IPV6 +/// Configure the outgoing IPv6 multicast interface on the given socket. +/// @param sock The socket to configure +/// @param if_index_in Non-zero: use this interface index directly. Zero (default): probe for the first eligible +/// interface via foreach_eligible_ipv6_if(). On single-interface devices (typical for ESP32/ESP8266) this is always +/// correct. On multi-homed hosts the "first eligible" interface is arbitrary; pass the desired index explicitly. +/// @return true on success, false on failure (errno set). A no-op returning true on LWIP_SOCKETS and Zephyr. +bool set_ipv6_multicast_if(Socket *sock, uint32_t if_index_in = 0); +#endif // USE_NETWORK_IPV6 +#endif // USE_SOCKET_IMPL_BSD_SOCKETS || USE_SOCKET_IMPL_LWIP_SOCKETS + +/// 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/sonoff_d1/__init__.py b/esphome/components/sonoff_d1/__init__.py index 18b4d30d18..40a6c103db 100644 --- a/esphome/components/sonoff_d1/__init__.py +++ b/esphome/components/sonoff_d1/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@anatoly-savchenkov"] +DOMAIN = "sonoff_d1" diff --git a/esphome/components/sonoff_d1/sonoff_d1.cpp b/esphome/components/sonoff_d1/sonoff_d1.cpp index 3e5af2b51f..c72210e932 100644 --- a/esphome/components/sonoff_d1/sonoff_d1.cpp +++ b/esphome/components/sonoff_d1/sonoff_d1.cpp @@ -46,19 +46,13 @@ namespace esphome::sonoff_d1 { -static const char *const TAG = "sonoff_d1"; +ESPHOME_LOG_TAG(TAG, "sonoff_d1"); // Protocol constants static constexpr size_t SONOFF_D1_ACK_SIZE = 7; static constexpr size_t SONOFF_D1_MAX_CMD_SIZE = 17; -uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { - uint8_t crc = 0; - for (size_t i = 2; i < len - 1; i++) { - crc += cmd[i]; - } - return crc; -} +uint8_t SonoffD1Output::calc_checksum_(const uint8_t *cmd, const size_t len) { return sum_bytes(cmd + 2, len - 3); } void SonoffD1Output::populate_checksum_(uint8_t *cmd, const size_t len) { // Update the checksum diff --git a/esphome/components/sound_level/__init__.py b/esphome/components/sound_level/__init__.py index e69de29bb2..93f0f4b2e3 100644 --- a/esphome/components/sound_level/__init__.py +++ b/esphome/components/sound_level/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sound_level" 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.cpp b/esphome/components/sound_level/sound_level.cpp index 99ab7932d6..1e51e45b7b 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -9,7 +9,7 @@ namespace esphome::sound_level { -static const char *const TAG = "sound_level"; +ESPHOME_LOG_TAG(TAG, "sound_level"); static const uint32_t MAX_FILL_DURATION_MS = 30; static const uint32_t RING_BUFFER_DURATION_MS = 120; @@ -171,7 +171,7 @@ bool SoundLevelComponent::start_() { (stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame; std::shared_ptr temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); if (temp_ring_buffer == nullptr) { - this->status_momentary_error("ring_buffer", 15000); + this->status_momentary_error(15000); return false; } @@ -180,7 +180,7 @@ bool SoundLevelComponent::start_() { this->audio_source_ = audio::RingBufferAudioSource::create( temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast(bytes_per_frame)); if (this->audio_source_ == nullptr) { - this->status_momentary_error("audio_source", 15000); + this->status_momentary_error(15000); return false; } this->ring_buffer_ = temp_ring_buffer; 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..c08d92f9d8 100644 --- a/esphome/components/spa06_base/__init__.py +++ b/esphome/components/spa06_base/__init__.py @@ -19,6 +19,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@danielkent-net"] +DOMAIN = "spa06_base" spa06_ns = cg.esphome_ns.namespace("spa06_base") @@ -181,9 +182,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 +194,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/spa06_base/spa06_base.cpp b/esphome/components/spa06_base/spa06_base.cpp index d3de5168e4..02defd0773 100644 --- a/esphome/components/spa06_base/spa06_base.cpp +++ b/esphome/components/spa06_base/spa06_base.cpp @@ -6,7 +6,9 @@ namespace esphome::spa06_base { -static const char *const TAG = "spa06"; +ESPHOME_LOG_TAG(TAG, "spa06"); + +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; // Sign extension function for <=16 bit types inline int16_t decode16(uint8_t msb, uint8_t lsb, size_t bits, size_t head = 0) { @@ -243,7 +245,7 @@ void SPA06Component::update() { } // Queue a background task for retrieving the measurement - this->set_timeout("measurement", this->conversion_time_, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, this->conversion_time_, [this]() { float raw_temperature; float temperature = 0.0; float pressure = 0.0; diff --git a/esphome/components/spa06_i2c/__init__.py b/esphome/components/spa06_i2c/__init__.py index e69de29bb2..928c6c157b 100644 --- a/esphome/components/spa06_i2c/__init__.py +++ b/esphome/components/spa06_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "spa06_i2c" diff --git a/esphome/components/spa06_i2c/spa06_i2c.cpp b/esphome/components/spa06_i2c/spa06_i2c.cpp index 4970b0822d..cdb0818d39 100644 --- a/esphome/components/spa06_i2c/spa06_i2c.cpp +++ b/esphome/components/spa06_i2c/spa06_i2c.cpp @@ -4,7 +4,7 @@ namespace esphome::spa06_i2c { -static const char *const TAG = "spa06_i2c"; +ESPHOME_LOG_TAG(TAG, "spa06_i2c"); void SPA06I2CComponent::dump_config() { LOG_I2C_DEVICE(this); diff --git a/esphome/components/spa06_spi/__init__.py b/esphome/components/spa06_spi/__init__.py index e69de29bb2..b473921999 100644 --- a/esphome/components/spa06_spi/__init__.py +++ b/esphome/components/spa06_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "spa06_spi" diff --git a/esphome/components/spa06_spi/spa06_spi.cpp b/esphome/components/spa06_spi/spa06_spi.cpp index 9f3683d219..08d725d4f8 100644 --- a/esphome/components/spa06_spi/spa06_spi.cpp +++ b/esphome/components/spa06_spi/spa06_spi.cpp @@ -13,7 +13,7 @@ inline constexpr uint8_t SPA06_SPI_WRITE = 0x7F; namespace esphome::spa06_spi { -static const char *const TAG = "spa06_spi"; +ESPHOME_LOG_TAG(TAG, "spa06_spi"); void SPA06SPIComponent::dump_config() { SPA06Component::dump_config(); diff --git a/esphome/components/spd2010/__init__.py b/esphome/components/spd2010/__init__.py new file mode 100644 index 0000000000..67a78e5f28 --- /dev/null +++ b/esphome/components/spd2010/__init__.py @@ -0,0 +1,3 @@ +CODEOWNERS = ["@grischard"] +DEPENDENCIES = ["i2c"] +DOMAIN = "spd2010" diff --git a/esphome/components/spd2010/spd2010_touchscreen.cpp b/esphome/components/spd2010/spd2010_touchscreen.cpp new file mode 100644 index 0000000000..94fad87d7d --- /dev/null +++ b/esphome/components/spd2010/spd2010_touchscreen.cpp @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: 2023 Espressif Systems (Shanghai) CO LTD +// SPDX-License-Identifier: Apache-2.0 +// Protocol adapted from esp_lcd_touch_spd2010 2.0.1 for ESPHome. +// https://github.com/espressif/esp-bsp/tree/master/components/lcd_touch/esp_lcd_touch_spd2010 + +#include "spd2010_touchscreen.h" + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::spd2010 { + +ESPHOME_LOG_TAG(TAG, "spd2010.touchscreen"); + +static constexpr uint16_t REG_CLEAR_INTERRUPT = 0x0002; +static constexpr uint16_t REG_CPU_START = 0x0004; +static constexpr uint16_t REG_STATUS = 0x0020; +static constexpr uint16_t REG_VERSION = 0x0026; +static constexpr uint16_t REG_TOUCH_START = 0x0046; +static constexpr uint16_t REG_POINT_MODE = 0x0050; +static constexpr uint16_t REG_HDP_STATUS = 0x02FC; +static constexpr uint16_t REG_HDP = 0x0300; + +// Low status byte +static constexpr uint8_t STATUS_POINT = 0x01; +static constexpr uint8_t STATUS_GESTURE = 0x02; +static constexpr uint8_t STATUS_AUX = 0x08; +// High status byte +static constexpr uint8_t STATUS_BIOS = 0x40; +static constexpr uint8_t STATUS_CPU = 0x20; +static constexpr uint8_t STATUS_RUNNING = 0x08; + +static constexpr uint8_t HDP_STATUS_DONE = 0x82; +static constexpr uint8_t HDP_STATUS_MORE = 0x00; + +static constexpr uint8_t MAX_CONTACT_ID = 0x0A; +static constexpr uint8_t GESTURE_REPORT_ID = 0xF6; +static constexpr size_t REPORT_HEADER_LENGTH = 4; +static constexpr size_t CONTACT_LENGTH = 6; +static constexpr size_t MAX_REPORT_LENGTH = REPORT_HEADER_LENGTH + 10 * CONTACT_LENGTH; +static constexpr uint8_t MAX_DRAIN_READS = 8; + +static constexpr uint32_t RESET_PULSE_WIDTH = + 20; // longer than the datasheet says, but the controller seems to need it +static constexpr uint32_t RESET_DELAY = 20; + +bool SPD2010Touchscreen::read_register_(uint16_t reg, uint8_t *data, size_t length) const { + const uint8_t command[] = {static_cast(reg), static_cast(reg >> 8u)}; + if (this->write(command, sizeof(command)) != i2c::ERROR_OK) + return false; + // The controller needs a STOP and a delay before a commandless receive. + delay_microseconds_safe(200); + return this->read(data, length) == i2c::ERROR_OK; +} + +bool SPD2010Touchscreen::write_command_(uint16_t reg, uint16_t value) const { + const uint8_t command[]{ + static_cast(reg), + static_cast(reg >> 8u), + static_cast(value), + static_cast(value >> 8u), + }; + return this->write(command, sizeof(command)) == i2c::ERROR_OK; +} + +bool SPD2010Touchscreen::clear_interrupt_() const { return this->write_command_(REG_CLEAR_INTERRUPT, 1); } + +void SPD2010Touchscreen::setup() { + if (this->x_raw_max_ == this->x_raw_min_) + this->x_raw_max_ = this->display_->get_native_width() - 1; + if (this->y_raw_max_ == this->y_raw_min_) + this->y_raw_max_ = this->display_->get_native_height() - 1; + if (this->reset_pin_ != nullptr) { + this->reset_pin_->setup(); + this->reset_pin_->digital_write(false); + this->set_timeout(RESET_PULSE_WIDTH, [this] { + this->reset_pin_->digital_write(true); + this->set_timeout(RESET_DELAY, [this] { this->initialize_(); }); + }); + } else { + this->initialize_(); + } +} + +void SPD2010Touchscreen::initialize_() { + uint8_t version[18]; + if (!this->read_register_(REG_VERSION, version, sizeof(version))) { + this->mark_failed(LOG_STR("Failed to read firmware version")); + return; + } + ESP_LOGD(TAG, "Firmware version: 0x%04X", encode_uint16(version[5], version[4])); + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); + } + this->ready_ = true; +} + +bool SPD2010Touchscreen::finish_report_() { + // A corrupt status must not cause an unbounded drain loop or buffer overflow. + for (uint8_t attempt = 0; attempt != MAX_DRAIN_READS; attempt++) { + uint8_t status[8]; + if (!this->read_register_(REG_HDP_STATUS, status, sizeof(status))) + return false; + if (status[5] == HDP_STATUS_DONE) + return this->clear_interrupt_(); + const uint16_t length = encode_uint16(status[3], status[2]); + if (status[5] != HDP_STATUS_MORE || length == 0 || length > MAX_REPORT_LENGTH) + break; + uint8_t remaining[MAX_REPORT_LENGTH]; + if (!this->read_register_(REG_HDP, remaining, length)) + return false; + } + ESP_LOGW(TAG, "Invalid or unfinished report"); + (void) this->clear_interrupt_(); + return false; +} + +// Returns true only when touch data was read correctly and should be published. +bool SPD2010Touchscreen::read_data_() { + uint8_t status[4]; + if (!this->read_register_(REG_STATUS, status, sizeof(status))) { + this->status_set_warning(LOG_STR("Failed to read status")); + return false; + } + const uint8_t status_low = status[0]; + const uint8_t status_high = status[1]; + const uint16_t length = encode_uint16(status[3], status[2]); + + if (status_high & STATUS_BIOS) { + this->status_set_warning(LOG_STR("BIOS status; start CPU")); + (void) (this->clear_interrupt_() && this->write_command_(REG_CPU_START, 1)); + return false; + } + if (status_high & STATUS_CPU) { + this->status_set_warning(LOG_STR("CPU status; stop point mode, start touch")); + (void) (this->write_command_(REG_POINT_MODE, 0) && this->write_command_(REG_TOUCH_START, 0) && + this->clear_interrupt_()); + return false; + } + if ((status_high & STATUS_RUNNING) && length == 0) { + if (!this->clear_interrupt_()) { + this->status_set_warning(LOG_STR("Failed to clear interrupt")); + return false; + } + this->status_clear_warning(); + return true; + } + if (!(status_low & (STATUS_POINT | STATUS_GESTURE))) { + if ((status_high & STATUS_RUNNING) && (status_low & STATUS_AUX) && !this->clear_interrupt_()) { + this->status_set_warning(LOG_STR("Failed to clear interrupt")); + return false; + } + this->status_clear_warning(); + return false; + } + if (length < REPORT_HEADER_LENGTH || length > MAX_REPORT_LENGTH || + (length - REPORT_HEADER_LENGTH) % CONTACT_LENGTH != 0) { + ESP_LOGW(TAG, "Invalid report length: %u", length); + this->status_set_warning(LOG_STR("Invalid report length")); + (void) this->clear_interrupt_(); + return false; + } + uint8_t report[MAX_REPORT_LENGTH]; + if (!this->read_register_(REG_HDP, report, length) || !this->finish_report_()) { + this->status_set_warning(LOG_STR("Failed to read report")); + return false; + } + // Gesture reports are not used. + if (!(status_low & STATUS_POINT) || + (length > REPORT_HEADER_LENGTH && report[REPORT_HEADER_LENGTH] == GESTURE_REPORT_ID)) { + this->status_clear_warning(); + return false; + } + // Check every ID before adding any point, so a bad report leaves the stored touches unchanged. + for (size_t offset = REPORT_HEADER_LENGTH; offset < length; offset += CONTACT_LENGTH) { + if (report[offset] > MAX_CONTACT_ID) { + this->status_set_warning(LOG_STR("Invalid contact ID")); + return false; + } + } + for (size_t offset = REPORT_HEADER_LENGTH; offset < length; offset += CONTACT_LENGTH) { + const uint8_t *contact = report + offset; + // Zero strength marks a released contact. + if (contact[4] == 0) + continue; + const uint16_t x = ((contact[3] & 0xF0u) << 4u) | contact[1]; + const uint16_t y = ((contact[3] & 0x0Fu) << 8u) | contact[2]; + this->add_raw_touch_position_(contact[0], x, y, contact[4]); + } + this->status_clear_warning(); + return true; +} + +void SPD2010Touchscreen::update_touches() { + if (!this->ready_ || !this->read_data_()) + this->skip_update_ = true; +} + +void SPD2010Touchscreen::dump_config() { + ESP_LOGCONFIG(TAG, + "SPD2010 Touchscreen:\n" + " Raw X range: %d-%d\n" + " Raw Y range: %d-%d", + this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_); + LOG_I2C_DEVICE(this); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_PIN(" Reset Pin: ", this->reset_pin_); +} + +} // namespace esphome::spd2010 diff --git a/esphome/components/spd2010/spd2010_touchscreen.h b/esphome/components/spd2010/spd2010_touchscreen.h new file mode 100644 index 0000000000..8817396872 --- /dev/null +++ b/esphome/components/spd2010/spd2010_touchscreen.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/touchscreen/touchscreen.h" +#include "esphome/core/hal.h" + +namespace esphome::spd2010 { + +class SPD2010Touchscreen final : 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 initialize_(); + void update_touches() override; + bool read_data_(); + bool read_register_(uint16_t reg, uint8_t *data, size_t length) const; + bool write_command_(uint16_t reg, uint16_t value) const; + bool clear_interrupt_() const; + bool finish_report_(); + + InternalGPIOPin *interrupt_pin_{nullptr}; + GPIOPin *reset_pin_{nullptr}; + bool ready_{false}; +}; + +} // namespace esphome::spd2010 diff --git a/esphome/components/spd2010/touchscreen.py b/esphome/components/spd2010/touchscreen.py new file mode 100644 index 0000000000..7ead6a3418 --- /dev/null +++ b/esphome/components/spd2010/touchscreen.py @@ -0,0 +1,33 @@ +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 + +spd2010_ns = cg.esphome_ns.namespace("spd2010") +SPD2010Touchscreen = spd2010_ns.class_( + "SPD2010Touchscreen", touchscreen.Touchscreen, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + touchscreen.touchscreen_schema("50ms") + .extend( + { + cv.GenerateID(): cv.declare_id(SPD2010Touchscreen), + 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(0x53)) +) + + +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.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + if reset_pin := config.get(CONF_RESET_PIN): + cg.add(var.set_reset_pin(await cg.gpio_pin_expression(reset_pin))) diff --git a/esphome/components/speaker/__init__.py b/esphome/components/speaker/__init__.py index d0a743543e..d34cfc32c0 100644 --- a/esphome/components/speaker/__init__.py +++ b/esphome/components/speaker/__init__.py @@ -8,6 +8,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority AUTO_LOAD = ["audio"] CODEOWNERS = ["@jesserockz", "@kahrendt"] +DOMAIN = "speaker" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 78892bff22..159af15808 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -251,7 +251,7 @@ automation.register_apply_action( # so a trigger arg with the same name cannot shadow it. ( CONF_MEDIA_FILE, - audio.AudioFile.operator("ptr"), + audio.AudioFile.operator("const").operator("ptr"), lambda _, value: f"::{value}", ), (CONF_ANNOUNCEMENT, cg.bool_), diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 509984cfa2..a39a1ecbbe 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -21,7 +21,7 @@ static const uint32_t DECODE_TASK_STACK_SIZE = 3 * 1024; static const uint32_t INFO_ERROR_QUEUE_COUNT = 5; -static const char *const TAG = "speaker_media_player.pipeline"; +ESPHOME_LOG_TAG(TAG, "speaker_media_player.pipeline"); enum EventGroupBits : uint32_t { // MESSAGE_* bits are only set by their respective tasks @@ -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/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 9ce50d7b76..8ab4128e92 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -50,7 +50,12 @@ static const uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20; static const UBaseType_t MEDIA_PIPELINE_TASK_PRIORITY = 1; static const UBaseType_t ANNOUNCEMENT_PIPELINE_TASK_PRIORITY = 1; -static const char *const TAG = "speaker_media_player"; +ESPHOME_LOG_TAG(TAG, "speaker_media_player"); + +static constexpr uint32_t UNPAUSE_MEDIA_INTERVAL_ID = 0; +static constexpr uint32_t NEXT_ANNOUNCEMENT_TIMEOUT_ID = 1; +static constexpr uint32_t NEXT_MEDIA_TIMEOUT_ID = 2; +static constexpr uint32_t UNPAUSE_ANNOUNCEMENT_INTERVAL_ID = 3; void SpeakerMediaPlayer::setup() { #ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF @@ -112,13 +117,13 @@ void SpeakerMediaPlayer::set_playlist_delay_ms(AudioPipelineType pipeline_type, void SpeakerMediaPlayer::stop_and_unpause_media_() { this->media_pipeline_->stop(); this->unpause_media_remaining_ = 3; - this->set_interval("unpause_med", 50, [this]() { + this->set_interval(UNPAUSE_MEDIA_INTERVAL_ID, 50, [this]() { if (this->media_pipeline_state_ == AudioPipelineState::STOPPED) { - this->cancel_interval("unpause_med"); + this->cancel_interval(UNPAUSE_MEDIA_INTERVAL_ID); this->media_pipeline_->set_pause_state(false); this->is_paused_ = false; } else if (--this->unpause_media_remaining_ == 0) { - this->cancel_interval("unpause_med"); + this->cancel_interval(UNPAUSE_MEDIA_INTERVAL_ID); } }); } @@ -152,8 +157,9 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { if (!enqueue) { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause - this->cancel_timeout("next_ann"); + this->cancel_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID); 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()) { @@ -165,8 +171,9 @@ void SpeakerMediaPlayer::watch_media_commands_() { } else { if (!enqueue) { // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause - this->cancel_timeout("next_media"); + this->cancel_timeout(NEXT_MEDIA_TIMEOUT_ID); 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 +229,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. @@ -234,23 +241,25 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { #endif if (this->announcement_pipeline_ != nullptr) { - this->cancel_timeout("next_ann"); + this->cancel_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID); 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]() { + this->set_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID, 50, [this]() { if (this->announcement_pipeline_state_ == AudioPipelineState::STOPPED) { - this->cancel_interval("unpause_ann"); + this->cancel_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID); this->announcement_pipeline_->set_pause_state(false); } else if (--this->unpause_announcement_remaining_ == 0) { - this->cancel_interval("unpause_ann"); + this->cancel_interval(UNPAUSE_ANNOUNCEMENT_INTERVAL_ID); } }); } } else { if (this->media_pipeline_ != nullptr) { - this->cancel_timeout("next_media"); + this->cancel_timeout(NEXT_MEDIA_TIMEOUT_ID); this->media_playlist_.clear(); + this->media_item_failed_ = false; this->stop_and_unpause_media_(); } } @@ -351,8 +360,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 +373,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 +384,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 @@ -395,10 +413,13 @@ void SpeakerMediaPlayer::loop() { this->announcement_pipeline_->set_pause_state(true); // Internally unpause the pipeline after the delay between playlist items. Announcements do not follow the // media player's pause state. - this->set_timeout("next_ann", timeout_ms, [this]() { this->announcement_pipeline_->set_pause_state(false); }); + this->set_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID, timeout_ms, + [this]() { this->announcement_pipeline_->set_pause_state(false); }); } } } 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 +433,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 @@ -434,11 +460,13 @@ void SpeakerMediaPlayer::loop() { this->media_pipeline_->set_pause_state(true); // Internally unpause the pipeline after the delay between playlist items, if the media player state is // not paused. - this->set_timeout("next_media", timeout_ms, + this->set_timeout(NEXT_MEDIA_TIMEOUT_ID, timeout_ms, [this]() { this->media_pipeline_->set_pause_state(this->is_paused_); }); } } } 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 +499,7 @@ void SpeakerMediaPlayer::loop() { #endif } -void SpeakerMediaPlayer::play_file(audio::AudioFile *media_file, bool announcement, bool enqueue) { +void SpeakerMediaPlayer::play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue) { if (!this->is_ready()) { // Ignore any commands sent before the media player is setup return; diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index 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 01e9ca042e..277b36a171 100644 --- a/esphome/components/speaker/speaker.h +++ b/esphome/components/speaker/speaker.h @@ -127,9 +127,9 @@ class Speaker { #endif State state_{STATE_STOPPED}; + bool mute_state_{false}; audio::AudioStreamInfo audio_stream_info_; float volume_{1.0f}; - bool mute_state_{false}; #ifdef USE_AUDIO_DAC audio_dac::AudioDac *audio_dac_{nullptr}; diff --git a/esphome/components/speaker_source/__init__.py b/esphome/components/speaker_source/__init__.py index e69de29bb2..ebe19a4ead 100644 --- a/esphome/components/speaker_source/__init__.py +++ b/esphome/components/speaker_source/__init__.py @@ -0,0 +1 @@ +DOMAIN = "speaker_source" 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 215f3942d5..657c26af80 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -11,7 +11,7 @@ namespace esphome::speaker_source { static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20; -static const char *const TAG = "speaker_source_media_player"; +ESPHOME_LOG_TAG(TAG, "speaker_source_media_player"); // SourceBinding method implementations (defined here because SpeakerSourceMediaPlayer is forward-declared in the // header) @@ -196,10 +196,19 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so vTaskDelay(pdMS_TO_TICKS(timeout_ms)); return 0; } + // Reserve frames before the speaker can play them and report their progress, + // then release the portion it did not accept. + const uint32_t reserved_frames = stream_info.bytes_to_frames(length); + ps.pending_frames.fetch_add(reserved_frames, std::memory_order_relaxed); size_t bytes_written = ps.speaker->play(data, length, pdMS_TO_TICKS(timeout_ms)); - if (bytes_written > 0) { - // Track frames sent to speaker for this source - ps.pending_frames.fetch_add(stream_info.bytes_to_frames(bytes_written), std::memory_order_relaxed); + const uint32_t unused_frames = reserved_frames - stream_info.bytes_to_frames(bytes_written); + if (unused_frames > 0) { + // The main loop may reset pending_frames while play() is blocked. + // Clamp the release so that a concurrent reset cannot cause underflow. + uint32_t pending = ps.pending_frames.load(std::memory_order_relaxed); + while (!ps.pending_frames.compare_exchange_weak(pending, pending - std::min(pending, unused_frames), + std::memory_order_relaxed)) { + } } return bytes_written; } diff --git a/esphome/components/speed/__init__.py b/esphome/components/speed/__init__.py index b75374863b..9f048ef37d 100644 --- a/esphome/components/speed/__init__.py +++ b/esphome/components/speed/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "speed" + speed_ns = cg.esphome_ns.namespace("speed") diff --git a/esphome/components/speed/fan/speed_fan.cpp b/esphome/components/speed/fan/speed_fan.cpp index af46ef52e1..322c638d4b 100644 --- a/esphome/components/speed/fan/speed_fan.cpp +++ b/esphome/components/speed/fan/speed_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::speed { -static const char *const TAG = "speed.fan"; +ESPHOME_LOG_TAG(TAG, "speed.fan"); void SpeedFan::setup() { // Construct traits before restore so preset modes can be looked up by index diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index dd2dce01c5..d6949cc590 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -18,6 +18,7 @@ from esphome.components.esp32 import ( VARIANT_ESP32S31, only_on_variant, ) +from esphome.components.psram import DOMAIN as PSRAM_DOMAIN from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -44,6 +45,7 @@ import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@esphome/core", "@clydebarrow"] +DOMAIN = "spi" spi_ns = cg.esphome_ns.namespace("spi") SPIComponent = spi_ns.class_("SPIComponent", cg.Component) QuadSPIComponent = spi_ns.class_("QuadSPIComponent", cg.Component) @@ -157,7 +159,7 @@ def _validate_psram_dma(value: Any) -> bool: msg_prefix="PSRAM DMA", ), cv.require_framework_version(esp_idf=cv.Version(5, 5, 3)), - cv.requires_component("psram"), + cv.requires_component(PSRAM_DOMAIN), )(value) diff --git a/esphome/components/spi/spi.cpp b/esphome/components/spi/spi.cpp index dfdc9fa624..343fcd5488 100644 --- a/esphome/components/spi/spi.cpp +++ b/esphome/components/spi/spi.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { -const char *const TAG = "spi"; +ESPHOME_LOG_TAG(TAG, "spi"); SPIDelegate *const SPIDelegate::NULL_DELEGATE = // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) new SPIDelegateDummy(); diff --git a/esphome/components/spi/spi_arduino.cpp b/esphome/components/spi/spi_arduino.cpp index ae2d2906ed..8ad868ad2e 100644 --- a/esphome/components/spi/spi_arduino.cpp +++ b/esphome/components/spi/spi_arduino.cpp @@ -4,7 +4,7 @@ namespace esphome::spi { #if defined(USE_ARDUINO) && !defined(USE_ESP32) -static const char *const TAG = "spi"; +ESPHOME_LOG_TAG(TAG, "spi"); class SPIDelegateHw : public SPIDelegate { public: SPIDelegateHw(SPIInterface channel, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin) diff --git a/esphome/components/spi/spi_esp_idf.cpp b/esphome/components/spi/spi_esp_idf.cpp index 95b5e4f14b..7b51bfaa69 100644 --- a/esphome/components/spi/spi_esp_idf.cpp +++ b/esphome/components/spi/spi_esp_idf.cpp @@ -8,7 +8,7 @@ namespace esphome::spi { #ifdef USE_ESP32 -static const char *const TAG = "spi"; +ESPHOME_LOG_TAG(TAG, "spi"); static const size_t MAX_TRANSFER_SIZE = 4092; // dictated by ESP-IDF API. #ifdef USE_SPI_PSRAM_DMA diff --git a/esphome/components/spi_device/__init__.py b/esphome/components/spi_device/__init__.py index bd2bfe6452..3f64dfc3d1 100644 --- a/esphome/components/spi_device/__init__.py +++ b/esphome/components/spi_device/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["spi"] CODEOWNERS = ["@clydebarrow"] +DOMAIN = "spi_device" MULTI_CONF = True spi_device_ns = cg.esphome_ns.namespace("spi_device") diff --git a/esphome/components/spi_device/spi_device.cpp b/esphome/components/spi_device/spi_device.cpp index bdf5978bc7..f89b9f624f 100644 --- a/esphome/components/spi_device/spi_device.cpp +++ b/esphome/components/spi_device/spi_device.cpp @@ -5,7 +5,7 @@ namespace esphome::spi_device { -static const char *const TAG = "spi_device"; +ESPHOME_LOG_TAG(TAG, "spi_device"); void SPIDeviceComponent::setup() { this->spi_setup(); } diff --git a/esphome/components/spi_led_strip/__init__.py b/esphome/components/spi_led_strip/__init__.py index 850a1f6e02..2d856e1b10 100644 --- a/esphome/components/spi_led_strip/__init__.py +++ b/esphome/components/spi_led_strip/__init__.py @@ -1,2 +1,3 @@ CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["spi"] +DOMAIN = "spi_led_strip" diff --git a/esphome/components/split_buffer/__init__.py b/esphome/components/split_buffer/__init__.py index be7472936f..e66c0c6efa 100644 --- a/esphome/components/split_buffer/__init__.py +++ b/esphome/components/split_buffer/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@jesserockz"] +DOMAIN = "split_buffer" # Allows split_buffer to be configured in yaml, to allow use of the C++ api. diff --git a/esphome/components/split_buffer/split_buffer.cpp b/esphome/components/split_buffer/split_buffer.cpp index 526a19c71c..56b1d326a3 100644 --- a/esphome/components/split_buffer/split_buffer.cpp +++ b/esphome/components/split_buffer/split_buffer.cpp @@ -1,22 +1,24 @@ #include "split_buffer.h" +#include +#include + #include "esphome/core/helpers.h" #include "esphome/core/log.h" namespace esphome::split_buffer { -static constexpr const char *const TAG = "split_buffer"; +ESPHOME_LOG_TAG(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 21863e2621..fe096514d0 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -23,6 +23,7 @@ from esphome.helpers import docs_url AUTO_LOAD = ["number", "switch"] CODEOWNERS = ["@kbx81"] +DOMAIN = "sprinkler" CONF_AUTO_ADVANCE_SWITCH = "auto_advance_switch" CONF_DIVIDER = "divider" @@ -66,19 +67,10 @@ SprinklerControllerSwitch = sprinkler_ns.class_( "SprinklerControllerSwitch", switch.Switch, cg.Component ) -ClearQueuedValvesAction = sprinkler_ns.class_( - "ClearQueuedValvesAction", 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) @@ -415,57 +407,61 @@ CONFIG_SCHEMA = cv.All( _VALVE_AND_DURATION = ((CONF_VALVE_NUMBER, cg.size_t), (CONF_RUN_DURATION, cg.uint32)) -automation.register_apply_action( - "sprinkler.set_divider", - SPRINKLER_ACTION_SET_DIVIDER_SCHEMA, - automation.ApplyField(CONF_DIVIDER, "set_divider", cg.uint32), -) - -automation.register_apply_action( - "sprinkler.set_multiplier", - SPRINKLER_ACTION_SET_MULTIPLIER_SCHEMA, - automation.ApplyField(CONF_MULTIPLIER, "set_multiplier", cg.float_), -) - -automation.register_apply_action( - "sprinkler.queue_valve", - SPRINKLER_ACTION_QUEUE_VALVE_SCHEMA, - automation.ApplyCall("queue_valve({}, {})", _VALVE_AND_DURATION), -) - -automation.register_apply_action( - "sprinkler.set_repeat", - SPRINKLER_ACTION_REPEAT_SCHEMA, - automation.ApplyField(CONF_REPEAT, "set_repeat", cg.uint32), -) - -automation.register_apply_action( - "sprinkler.set_valve_run_duration", - SPRINKLER_ACTION_SET_RUN_DURATION_SCHEMA, - automation.ApplyCall("set_valve_run_duration({}, {})", _VALVE_AND_DURATION), -) - - -@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) +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()")), +): + automation.register_apply_action(_name, _schema, _apply) @automation.register_action( @@ -485,45 +481,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 14d9bd20ec..1de0e1ee11 100644 --- a/esphome/components/sprinkler/automation.h +++ b/esphome/components/sprinkler/automation.h @@ -6,36 +6,6 @@ namespace esphome::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 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) {} @@ -64,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 cdec158126..eb9e61958f 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -10,7 +10,7 @@ namespace esphome::sprinkler { -static const char *const TAG = "sprinkler"; +ESPHOME_LOG_TAG(TAG, "sprinkler"); void SprinklerControllerNumber::setup() { float value; @@ -322,13 +322,11 @@ optional SprinklerValveRunRequest::valve_as_opt() { SprinklerValveOperator *SprinklerValveRunRequest::valve_operator() { return this->valve_op_; } Sprinkler::Sprinkler() : Sprinkler("") {} -Sprinkler::Sprinkler(const char *name) : name_(name) { - // The `name` is stored for dump_config logging - this->timer_.init(2); - // Timer names only need to be unique within this component instance - this->timer_.push_back({"sm", false, 0, 0, [this]() { this->sm_timer_callback_(); }}); - this->timer_.push_back({"vs", false, 0, 0, [this]() { this->valve_selection_callback_(); }}); -} +// The `name` is stored for dump_config logging; the scheduler keys each timer by its SprinklerTimerIndex +Sprinkler::Sprinkler(const char *name) + : name_(name), + timer_{{{false, 0, 0, [this]() { this->sm_timer_callback_(); }}, + {false, 0, 0, [this]() { this->valve_selection_callback_(); }}}} {} void Sprinkler::setup() { this->all_valves_off_(true); @@ -1510,7 +1508,7 @@ const LogString *Sprinkler::state_as_str_(SprinklerState state) { void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) { if (this->timer_duration_(timer_index) > 0) { - this->set_timeout(this->timer_[timer_index].name, this->timer_duration_(timer_index), + this->set_timeout(static_cast(timer_index), this->timer_duration_(timer_index), this->timer_cbf_(timer_index)); this->timer_[timer_index].start_time = millis(); this->timer_[timer_index].active = true; @@ -1521,7 +1519,7 @@ void Sprinkler::start_timer_(const SprinklerTimerIndex timer_index) { bool Sprinkler::cancel_timer_(const SprinklerTimerIndex timer_index) { this->timer_[timer_index].active = false; - return this->cancel_timeout(this->timer_[timer_index].name); + return this->cancel_timeout(static_cast(timer_index)); } bool Sprinkler::timer_active_(const SprinklerTimerIndex timer_index) { return this->timer_[timer_index].active; } diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index 2499a0a591..c51824d883 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -7,6 +7,7 @@ #include "esphome/components/number/number.h" #include "esphome/components/switch/switch.h" +#include #include namespace esphome::sprinkler { @@ -49,7 +50,6 @@ struct SprinklerQueueItem { }; struct SprinklerTimer { - const char *name; bool active; uint32_t time; uint32_t start_time; @@ -564,8 +564,8 @@ class Sprinkler final : public Component { /// Sprinkler valve operator objects std::vector valve_op_{2}; - /// Valve control timers - FixedVector enforces that this can never grow beyond init() size - FixedVector timer_; + /// Valve control timers, indexed by SprinklerTimerIndex (also used as the scheduler id) + std::array timer_; /// Other Sprinkler instances we should be aware of (used to check if pumps are in use) std::vector other_controllers_; diff --git a/esphome/components/sps30/__init__.py b/esphome/components/sps30/__init__.py index e69de29bb2..4b06a323ab 100644 --- a/esphome/components/sps30/__init__.py +++ b/esphome/components/sps30/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sps30" 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/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 73fa4ef463..3a2ebb14c3 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -6,7 +6,7 @@ namespace esphome::sps30 { -static const char *const TAG = "sps30"; +ESPHOME_LOG_TAG(TAG, "sps30"); static const uint16_t SPS30_CMD_GET_ARTICLE_CODE = 0xD025; static const uint16_t SPS30_CMD_GET_SERIAL_NUMBER = 0xD033; diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index 26eda3b4ea..9577f9d4f7 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -18,6 +18,8 @@ from esphome.const import ( from esphome.cpp_generator import MockObj from esphome.types import ConfigType +DOMAIN = "ssd1306_base" + ssd1306_base_ns = cg.esphome_ns.namespace("ssd1306_base") SSD1306 = ssd1306_base_ns.class_("SSD1306", cg.PollingComponent, display.DisplayBuffer) SSD1306Model = ssd1306_base_ns.enum("SSD1306Model") diff --git a/esphome/components/ssd1306_base/ssd1306_base.cpp b/esphome/components/ssd1306_base/ssd1306_base.cpp index 5f1c6fca8f..ef7fa46e73 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.cpp +++ b/esphome/components/ssd1306_base/ssd1306_base.cpp @@ -5,7 +5,7 @@ namespace esphome::ssd1306_base { -static const char *const TAG = "ssd1306"; +ESPHOME_LOG_TAG(TAG, "ssd1306"); static const uint8_t SSD1306_MAX_CONTRAST = 255; static const uint8_t SSD1305_MAX_BRIGHTNESS = 255; diff --git a/esphome/components/ssd1306_i2c/__init__.py b/esphome/components/ssd1306_i2c/__init__.py index e69de29bb2..9389c74a44 100644 --- a/esphome/components/ssd1306_i2c/__init__.py +++ b/esphome/components/ssd1306_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1306_i2c" diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 00c864a217..1cdfed9f50 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::ssd1306_i2c { -static const char *const TAG = "ssd1306_i2c"; +ESPHOME_LOG_TAG(TAG, "ssd1306_i2c"); void I2CSSD1306::setup() { this->init_reset_(); diff --git a/esphome/components/ssd1306_spi/__init__.py b/esphome/components/ssd1306_spi/__init__.py index e69de29bb2..fc62e74a43 100644 --- a/esphome/components/ssd1306_spi/__init__.py +++ b/esphome/components/ssd1306_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1306_spi" diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.cpp b/esphome/components/ssd1306_spi/ssd1306_spi.cpp index 0534deeb03..8e2dcebc0b 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.cpp +++ b/esphome/components/ssd1306_spi/ssd1306_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1306_spi { -static const char *const TAG = "ssd1306_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1306_spi"); void SPISSD1306::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1322_base/__init__.py b/esphome/components/ssd1322_base/__init__.py index daa0345857..d66ae8d5c9 100644 --- a/esphome/components/ssd1322_base/__init__.py +++ b/esphome/components/ssd1322_base/__init__.py @@ -13,6 +13,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1322_base" ssd1322_base_ns = cg.esphome_ns.namespace("ssd1322_base") SSD1322 = ssd1322_base_ns.class_("SSD1322", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1322_base/ssd1322_base.cpp b/esphome/components/ssd1322_base/ssd1322_base.cpp index a8a39b9ab0..30ca4109ef 100644 --- a/esphome/components/ssd1322_base/ssd1322_base.cpp +++ b/esphome/components/ssd1322_base/ssd1322_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1322_base { -static const char *const TAG = "ssd1322"; +ESPHOME_LOG_TAG(TAG, "ssd1322"); static const uint8_t SSD1322_MAX_CONTRAST = 255; static const uint8_t SSD1322_COLORMASK = 0x0f; diff --git a/esphome/components/ssd1322_spi/__init__.py b/esphome/components/ssd1322_spi/__init__.py index e69de29bb2..52b9de59b2 100644 --- a/esphome/components/ssd1322_spi/__init__.py +++ b/esphome/components/ssd1322_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1322_spi" diff --git a/esphome/components/ssd1322_spi/ssd1322_spi.cpp b/esphome/components/ssd1322_spi/ssd1322_spi.cpp index 79f15508c3..38426b9eaf 100644 --- a/esphome/components/ssd1322_spi/ssd1322_spi.cpp +++ b/esphome/components/ssd1322_spi/ssd1322_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1322_spi { -static const char *const TAG = "ssd1322_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1322_spi"); void SPISSD1322::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1325_base/__init__.py b/esphome/components/ssd1325_base/__init__.py index e48a821825..fbbf329d53 100644 --- a/esphome/components/ssd1325_base/__init__.py +++ b/esphome/components/ssd1325_base/__init__.py @@ -13,6 +13,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1325_base" ssd1325_base_ns = cg.esphome_ns.namespace("ssd1325_base") SSD1325 = ssd1325_base_ns.class_("SSD1325", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1325_base/ssd1325_base.cpp b/esphome/components/ssd1325_base/ssd1325_base.cpp index b8d8f99999..17175447d9 100644 --- a/esphome/components/ssd1325_base/ssd1325_base.cpp +++ b/esphome/components/ssd1325_base/ssd1325_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1325_base { -static const char *const TAG = "ssd1325"; +ESPHOME_LOG_TAG(TAG, "ssd1325"); static const uint8_t SSD1325_MAX_CONTRAST = 127; static const uint8_t SSD1325_COLORMASK = 0x0f; diff --git a/esphome/components/ssd1325_spi/__init__.py b/esphome/components/ssd1325_spi/__init__.py index e69de29bb2..218baca080 100644 --- a/esphome/components/ssd1325_spi/__init__.py +++ b/esphome/components/ssd1325_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1325_spi" diff --git a/esphome/components/ssd1325_spi/ssd1325_spi.cpp b/esphome/components/ssd1325_spi/ssd1325_spi.cpp index af6a01c7f3..cb21eb09fe 100644 --- a/esphome/components/ssd1325_spi/ssd1325_spi.cpp +++ b/esphome/components/ssd1325_spi/ssd1325_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1325_spi { -static const char *const TAG = "ssd1325_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1325_spi"); void SPISSD1325::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1327_base/__init__.py b/esphome/components/ssd1327_base/__init__.py index 462f197696..2c9c0faf3b 100644 --- a/esphome/components/ssd1327_base/__init__.py +++ b/esphome/components/ssd1327_base/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1327_base" ssd1327_base_ns = cg.esphome_ns.namespace("ssd1327_base") SSD1327 = ssd1327_base_ns.class_("SSD1327", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1327_base/ssd1327_base.cpp b/esphome/components/ssd1327_base/ssd1327_base.cpp index ae02b836bf..7c8b490f61 100644 --- a/esphome/components/ssd1327_base/ssd1327_base.cpp +++ b/esphome/components/ssd1327_base/ssd1327_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1327_base { -static const char *const TAG = "ssd1327"; +ESPHOME_LOG_TAG(TAG, "ssd1327"); static const uint8_t SSD1327_MAX_CONTRAST = 127; static const uint8_t SSD1327_COLORMASK = 0x0f; diff --git a/esphome/components/ssd1327_i2c/__init__.py b/esphome/components/ssd1327_i2c/__init__.py index e69de29bb2..b093087fa1 100644 --- a/esphome/components/ssd1327_i2c/__init__.py +++ b/esphome/components/ssd1327_i2c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1327_i2c" diff --git a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp index 90fbcbd05c..98328c800c 100644 --- a/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp +++ b/esphome/components/ssd1327_i2c/ssd1327_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::ssd1327_i2c { -static const char *const TAG = "ssd1327_i2c"; +ESPHOME_LOG_TAG(TAG, "ssd1327_i2c"); void I2CSSD1327::setup() { this->init_reset_(); diff --git a/esphome/components/ssd1327_spi/__init__.py b/esphome/components/ssd1327_spi/__init__.py index e69de29bb2..f95fe10579 100644 --- a/esphome/components/ssd1327_spi/__init__.py +++ b/esphome/components/ssd1327_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1327_spi" diff --git a/esphome/components/ssd1327_spi/ssd1327_spi.cpp b/esphome/components/ssd1327_spi/ssd1327_spi.cpp index c6a64227fc..8816aadf6d 100644 --- a/esphome/components/ssd1327_spi/ssd1327_spi.cpp +++ b/esphome/components/ssd1327_spi/ssd1327_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1327_spi { -static const char *const TAG = "ssd1327_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1327_spi"); void SPISSD1327::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1331_base/__init__.py b/esphome/components/ssd1331_base/__init__.py index d1c0b585f8..2345acdf65 100644 --- a/esphome/components/ssd1331_base/__init__.py +++ b/esphome/components/ssd1331_base/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1331_base" ssd1331_base_ns = cg.esphome_ns.namespace("ssd1331_base") SSD1331 = ssd1331_base_ns.class_("SSD1331", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1331_base/ssd1331_base.cpp b/esphome/components/ssd1331_base/ssd1331_base.cpp index 7755e5a2a0..8d5ebb4dbb 100644 --- a/esphome/components/ssd1331_base/ssd1331_base.cpp +++ b/esphome/components/ssd1331_base/ssd1331_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1331_base { -static const char *const TAG = "ssd1331"; +ESPHOME_LOG_TAG(TAG, "ssd1331"); static const uint16_t SSD1331_COLORMASK = 0xffff; static const uint8_t SSD1331_MAX_CONTRASTA = 0x91; diff --git a/esphome/components/ssd1331_spi/__init__.py b/esphome/components/ssd1331_spi/__init__.py index e69de29bb2..4e37ffcaba 100644 --- a/esphome/components/ssd1331_spi/__init__.py +++ b/esphome/components/ssd1331_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1331_spi" diff --git a/esphome/components/ssd1331_spi/ssd1331_spi.cpp b/esphome/components/ssd1331_spi/ssd1331_spi.cpp index a5118cdfbe..d36f9b75af 100644 --- a/esphome/components/ssd1331_spi/ssd1331_spi.cpp +++ b/esphome/components/ssd1331_spi/ssd1331_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1331_spi { -static const char *const TAG = "ssd1331_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1331_spi"); void SPISSD1331::setup() { this->spi_setup(); diff --git a/esphome/components/ssd1351_base/__init__.py b/esphome/components/ssd1351_base/__init__.py index a516b4d951..f693004d63 100644 --- a/esphome/components/ssd1351_base/__init__.py +++ b/esphome/components/ssd1351_base/__init__.py @@ -7,6 +7,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] +DOMAIN = "ssd1351_base" ssd1351_base_ns = cg.esphome_ns.namespace("ssd1351_base") SSD1351 = ssd1351_base_ns.class_("SSD1351", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/ssd1351_base/ssd1351_base.cpp b/esphome/components/ssd1351_base/ssd1351_base.cpp index 3df575499d..7ab3a64ea8 100644 --- a/esphome/components/ssd1351_base/ssd1351_base.cpp +++ b/esphome/components/ssd1351_base/ssd1351_base.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1351_base { -static const char *const TAG = "ssd1351"; +ESPHOME_LOG_TAG(TAG, "ssd1351"); static const uint16_t SSD1351_COLORMASK = 0xffff; static const uint8_t SSD1351_MAX_CONTRAST = 15; diff --git a/esphome/components/ssd1351_spi/__init__.py b/esphome/components/ssd1351_spi/__init__.py index e69de29bb2..0cb07c8b5e 100644 --- a/esphome/components/ssd1351_spi/__init__.py +++ b/esphome/components/ssd1351_spi/__init__.py @@ -0,0 +1 @@ +DOMAIN = "ssd1351_spi" diff --git a/esphome/components/ssd1351_spi/ssd1351_spi.cpp b/esphome/components/ssd1351_spi/ssd1351_spi.cpp index f6d2323e9c..06d95e74a4 100644 --- a/esphome/components/ssd1351_spi/ssd1351_spi.cpp +++ b/esphome/components/ssd1351_spi/ssd1351_spi.cpp @@ -4,7 +4,7 @@ namespace esphome::ssd1351_spi { -static const char *const TAG = "ssd1351_spi"; +ESPHOME_LOG_TAG(TAG, "ssd1351_spi"); void SPISSD1351::setup() { this->spi_setup(); diff --git a/esphome/components/st7123/__init__.py b/esphome/components/st7123/__init__.py index 335bc238be..6e3a763eb4 100644 --- a/esphome/components/st7123/__init__.py +++ b/esphome/components/st7123/__init__.py @@ -2,5 +2,6 @@ import esphome.codegen as cg CODEOWNERS = ["@miniskipper"] DEPENDENCIES = ["i2c"] +DOMAIN = "st7123" st7123_ns = cg.esphome_ns.namespace("st7123") diff --git a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp index 117f975264..5cd986f697 100644 --- a/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp +++ b/esphome/components/st7123/touchscreen/st7123_touchscreen.cpp @@ -5,7 +5,7 @@ namespace esphome::st7123 { -static const char *const TAG = "st7123.touchscreen"; +ESPHOME_LOG_TAG(TAG, "st7123.touchscreen"); void ST7123Touchscreen::setup() { if (this->reset_pin_ != nullptr) { diff --git a/esphome/components/st7567_base/__init__.py b/esphome/components/st7567_base/__init__.py index 51225c5023..8fdc0a7f7a 100644 --- a/esphome/components/st7567_base/__init__.py +++ b/esphome/components/st7567_base/__init__.py @@ -14,6 +14,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@latonita"] +DOMAIN = "st7567_base" st7567_base_ns = cg.esphome_ns.namespace("st7567_base") ST7567 = st7567_base_ns.class_("ST7567", cg.PollingComponent, display.DisplayBuffer) diff --git a/esphome/components/st7567_base/st7567_base.cpp b/esphome/components/st7567_base/st7567_base.cpp index fff3aa7e03..aa83de6a8c 100644 --- a/esphome/components/st7567_base/st7567_base.cpp +++ b/esphome/components/st7567_base/st7567_base.cpp @@ -4,7 +4,7 @@ namespace esphome::st7567_base { -static const char *const TAG = "st7567"; +ESPHOME_LOG_TAG(TAG, "st7567"); void ST7567::setup() { this->init_internal_(this->get_buffer_length_()); diff --git a/esphome/components/st7567_i2c/__init__.py b/esphome/components/st7567_i2c/__init__.py index dd06cfffea..3c54c70741 100644 --- a/esphome/components/st7567_i2c/__init__.py +++ b/esphome/components/st7567_i2c/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "st7567_i2c" diff --git a/esphome/components/st7567_i2c/st7567_i2c.cpp b/esphome/components/st7567_i2c/st7567_i2c.cpp index c99815ecd6..657a60f954 100644 --- a/esphome/components/st7567_i2c/st7567_i2c.cpp +++ b/esphome/components/st7567_i2c/st7567_i2c.cpp @@ -3,7 +3,7 @@ namespace esphome::st7567_i2c { -static const char *const TAG = "st7567_i2c"; +ESPHOME_LOG_TAG(TAG, "st7567_i2c"); void I2CST7567::setup() { this->init_reset_(); diff --git a/esphome/components/st7567_spi/__init__.py b/esphome/components/st7567_spi/__init__.py index dd06cfffea..ed60104857 100644 --- a/esphome/components/st7567_spi/__init__.py +++ b/esphome/components/st7567_spi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "st7567_spi" diff --git a/esphome/components/st7567_spi/st7567_spi.cpp b/esphome/components/st7567_spi/st7567_spi.cpp index fae8c31381..fa9fb792dd 100644 --- a/esphome/components/st7567_spi/st7567_spi.cpp +++ b/esphome/components/st7567_spi/st7567_spi.cpp @@ -3,7 +3,7 @@ namespace esphome::st7567_spi { -static const char *const TAG = "st7567_spi"; +ESPHOME_LOG_TAG(TAG, "st7567_spi"); void SPIST7567::setup() { this->spi_setup(); diff --git a/esphome/components/st7701s/__init__.py b/esphome/components/st7701s/__init__.py index c58ce8a01e..812c6caa8f 100644 --- a/esphome/components/st7701s/__init__.py +++ b/esphome/components/st7701s/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "st7701s" diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index 62dfa6f413..176eb233e1 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -1,5 +1,7 @@ import esphome.codegen as cg +DOMAIN = "st7735" + st7735_ns = cg.esphome_ns.namespace("st7735") DEPRECATED_COMPONENT = """ diff --git a/esphome/components/st7735/st7735.cpp b/esphome/components/st7735/st7735.cpp index 7eb70c5c23..94b94d17a9 100644 --- a/esphome/components/st7735/st7735.cpp +++ b/esphome/components/st7735/st7735.cpp @@ -218,7 +218,7 @@ static constexpr uint8_t PROGMEM 100 }; // 100 ms delay // clang-format on -static const char *const TAG = "st7735"; +ESPHOME_LOG_TAG(TAG, "st7735"); ST7735::ST7735(ST7735Model model, int width, int height, int colstart, int rowstart, bool eightbitcolor, bool usebgr, bool invert_colors) diff --git a/esphome/components/st7789v/__init__.py b/esphome/components/st7789v/__init__.py index 7915cf119c..e67f480912 100644 --- a/esphome/components/st7789v/__init__.py +++ b/esphome/components/st7789v/__init__.py @@ -1,5 +1,7 @@ import esphome.codegen as cg +DOMAIN = "st7789v" + st7789v_ns = cg.esphome_ns.namespace("st7789v") DEPRECATED_COMPONENT = """ diff --git a/esphome/components/st7789v/st7789v.cpp b/esphome/components/st7789v/st7789v.cpp index 2e07e24522..343d196b50 100644 --- a/esphome/components/st7789v/st7789v.cpp +++ b/esphome/components/st7789v/st7789v.cpp @@ -4,7 +4,7 @@ namespace esphome::st7789v { -static const char *const TAG = "st7789v"; +ESPHOME_LOG_TAG(TAG, "st7789v"); #ifdef USE_ESP32 static constexpr size_t TEMP_BUFFER_SIZE = 1024; #else diff --git a/esphome/components/st7920/__init__.py b/esphome/components/st7920/__init__.py index e69de29bb2..a1512e4e57 100644 --- a/esphome/components/st7920/__init__.py +++ b/esphome/components/st7920/__init__.py @@ -0,0 +1 @@ +DOMAIN = "st7920" diff --git a/esphome/components/st7920/st7920.cpp b/esphome/components/st7920/st7920.cpp index 429ed9e57b..19a96aad60 100644 --- a/esphome/components/st7920/st7920.cpp +++ b/esphome/components/st7920/st7920.cpp @@ -5,7 +5,7 @@ namespace esphome::st7920 { -static const char *const TAG = "st7920"; +ESPHOME_LOG_TAG(TAG, "st7920"); // ST7920 COMMANDS static const uint8_t LCD_DATA = 0xFA; diff --git a/esphome/components/statsd/__init__.py b/esphome/components/statsd/__init__.py index b5a0586e31..0a0c4a59fb 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 import binary_sensor, sensor, socket +from esphome.components.const import CONF_HOST import esphome.config_validation as cv from esphome.const import ( CONF_BINARY_SENSORS, @@ -13,8 +14,8 @@ from esphome.types import ConfigType AUTO_LOAD = ["socket"] CODEOWNERS = ["@Links2004"] DEPENDENCIES = ["network"] +DOMAIN = "statsd" -CONF_HOST = "host" CONF_PREFIX = "prefix" statsd_component_ns = cg.esphome_ns.namespace("statsd") @@ -34,16 +35,21 @@ CONFIG_BINARY_SENSORS_SCHEMA = cv.Schema( } ) -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(StatsdComponent), - cv.Required(CONF_HOST): cv.string_strict, - cv.Optional(CONF_PORT, default=8125): cv.port, - cv.Optional(CONF_PREFIX, default=""): cv.string_strict, - cv.Optional(CONF_SENSORS): cv.ensure_list(CONFIG_SENSORS_SCHEMA), - cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list(CONFIG_BINARY_SENSORS_SCHEMA), - } -).extend(cv.polling_component_schema("10s")) +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(StatsdComponent), + cv.Required(CONF_HOST): cv.string_strict, + cv.Optional(CONF_PORT, default=8125): cv.port, + cv.Optional(CONF_PREFIX, default=""): cv.string_strict, + cv.Optional(CONF_SENSORS): cv.ensure_list(CONFIG_SENSORS_SCHEMA), + cv.Optional(CONF_BINARY_SENSORS): cv.ensure_list( + CONFIG_BINARY_SENSORS_SCHEMA + ), + } + ).extend(cv.polling_component_schema("10s")), + socket.consume_sockets(1, "statsd", socket.SocketType.UDP), +) async def to_code(config: ConfigType) -> None: diff --git a/esphome/components/statsd/statsd.cpp b/esphome/components/statsd/statsd.cpp index 2a56551255..1c9474bdb7 100644 --- a/esphome/components/statsd/statsd.cpp +++ b/esphome/components/statsd/statsd.cpp @@ -10,31 +10,18 @@ namespace esphome::statsd { // this is needed since statsD does not support fragmented UDP packets static const uint16_t SEND_THRESHOLD = 1024; -static const char *const TAG = "statsD"; +ESPHOME_LOG_TAG(TAG, "statsD"); void StatsdComponent::setup() { -#ifndef USE_ESP8266 - this->sock_ = esphome::socket::socket(AF_INET, SOCK_DGRAM, 0); - - struct sockaddr_in source; - source.sin_family = AF_INET; - source.sin_addr.s_addr = htonl(INADDR_ANY); - source.sin_port = htons(this->port_); - this->sock_->bind((struct sockaddr *) &source, sizeof(source)); + this->sock_ = esphome::socket::socket_udp_send(AF_INET, 0); + if (!this->sock_) { + this->mark_failed(LOG_STR("Could not create socket")); + return; + } this->destination_.sin_family = AF_INET; this->destination_.sin_port = htons(this->port_); this->destination_.sin_addr.s_addr = inet_addr(this->host_); -#endif -} - -StatsdComponent::~StatsdComponent() { -#ifndef USE_ESP8266 - if (!this->sock_) { - return; - } - this->sock_->close(); -#endif } void StatsdComponent::dump_config() { @@ -141,19 +128,7 @@ void StatsdComponent::update() { } void StatsdComponent::send_(std::string *out) { - if (out->empty()) { - return; - } -#ifdef USE_ESP8266 - IPAddress ip; - ip.fromString(this->host_); - - this->sock_.beginPacket(ip, this->port_); - this->sock_.write((const uint8_t *) out->c_str(), out->length()); - this->sock_.endPacket(); - -#else - if (!this->sock_) { + if (out->empty() || !this->sock_) { return; } @@ -162,7 +137,6 @@ void StatsdComponent::send_(std::string *out) { if (n_bytes != static_cast(out->length())) { ESP_LOGE(TAG, "Failed to send UDP packed (%d of %d)", n_bytes, out->length()); } -#endif } } // namespace esphome::statsd diff --git a/esphome/components/statsd/statsd.h b/esphome/components/statsd/statsd.h index 7cbde6d743..a12cc65810 100644 --- a/esphome/components/statsd/statsd.h +++ b/esphome/components/statsd/statsd.h @@ -20,17 +20,10 @@ #include "esphome/components/logger/logger.h" #endif -#ifdef USE_ESP8266 -#include "WiFiUdp.h" -#include "IPAddress.h" -#endif - namespace esphome::statsd { class StatsdComponent final : public PollingComponent { public: - ~StatsdComponent(); - void setup() override; void dump_config() override; void update() override; @@ -71,12 +64,8 @@ class StatsdComponent final : public PollingComponent { std::vector sensors_; -#ifdef USE_ESP8266 - WiFiUDP sock_; -#else - std::unique_ptr sock_; - struct sockaddr_in destination_; -#endif + std::unique_ptr sock_; + struct sockaddr_in destination_ {}; void send_(std::string *out); }; diff --git a/esphome/components/status/__init__.py b/esphome/components/status/__init__.py index e69de29bb2..dee924f6cf 100644 --- a/esphome/components/status/__init__.py +++ b/esphome/components/status/__init__.py @@ -0,0 +1 @@ +DOMAIN = "status" diff --git a/esphome/components/status/status_binary_sensor.cpp b/esphome/components/status/status_binary_sensor.cpp index 2c95be8569..192c03219f 100644 --- a/esphome/components/status/status_binary_sensor.cpp +++ b/esphome/components/status/status_binary_sensor.cpp @@ -12,7 +12,7 @@ namespace esphome::status { -static const char *const TAG = "status"; +ESPHOME_LOG_TAG(TAG, "status"); void StatusBinarySensor::update() { bool status = network::is_connected(); diff --git a/esphome/components/status_led/__init__.py b/esphome/components/status_led/__init__.py index 700296c0df..8bd98c0ef8 100644 --- a/esphome/components/status_led/__init__.py +++ b/esphome/components/status_led/__init__.py @@ -5,6 +5,8 @@ from esphome.const import CONF_ID, CONF_PIN from esphome.core import CoroPriority, coroutine_with_priority from esphome.types import ConfigType +DOMAIN = "status_led" + status_led_ns = cg.esphome_ns.namespace("status_led") StatusLED = status_led_ns.class_("StatusLED", cg.Component) diff --git a/esphome/components/status_led/light/status_led_light.cpp b/esphome/components/status_led/light/status_led_light.cpp index 341d3bfce5..6eeb9732a4 100644 --- a/esphome/components/status_led/light/status_led_light.cpp +++ b/esphome/components/status_led/light/status_led_light.cpp @@ -5,7 +5,7 @@ namespace esphome::status_led { -static const char *const TAG = "status_led"; +ESPHOME_LOG_TAG(TAG, "status_led"); void StatusLEDLightOutput::loop() { uint8_t new_state = App.get_app_state() & STATUS_LED_MASK; diff --git a/esphome/components/status_led/status_led.cpp b/esphome/components/status_led/status_led.cpp index ae37b3fae7..81b3b9e3af 100644 --- a/esphome/components/status_led/status_led.cpp +++ b/esphome/components/status_led/status_led.cpp @@ -4,7 +4,7 @@ namespace esphome::status_led { -static const char *const TAG = "status_led"; +ESPHOME_LOG_TAG(TAG, "status_led"); static constexpr uint32_t ERROR_PERIOD_MS = 250; static constexpr uint32_t ERROR_ON_MS = 150; diff --git a/esphome/components/stcc4/__init__.py b/esphome/components/stcc4/__init__.py new file mode 100644 index 0000000000..d0f16805b8 --- /dev/null +++ b/esphome/components/stcc4/__init__.py @@ -0,0 +1 @@ +DOMAIN = "stcc4" 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..ac7a998e5d --- /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 { + +ESPHOME_LOG_TAG(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 017fd95cd8..f5c1a30433 100644 --- a/esphome/components/stepper/__init__.py +++ b/esphome/components/stepper/__init__.py @@ -16,6 +16,7 @@ from esphome.const import ( from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.types import SafeExpType +DOMAIN = "stepper" IS_PLATFORM_COMPONENT = True stepper_ns = cg.esphome_ns.namespace("stepper") diff --git a/esphome/components/stepper/stepper.cpp b/esphome/components/stepper/stepper.cpp index 54df83782e..dd3040d898 100644 --- a/esphome/components/stepper/stepper.cpp +++ b/esphome/components/stepper/stepper.cpp @@ -4,7 +4,7 @@ namespace esphome::stepper { -static const char *const TAG = "stepper"; +ESPHOME_LOG_TAG(TAG, "stepper"); void Stepper::calculate_speed_(uint32_t now) { // delta t since last calculation in seconds diff --git a/esphome/components/sts3x/__init__.py b/esphome/components/sts3x/__init__.py index e69de29bb2..d7d6491925 100644 --- a/esphome/components/sts3x/__init__.py +++ b/esphome/components/sts3x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "sts3x" diff --git a/esphome/components/sts3x/sts3x.cpp b/esphome/components/sts3x/sts3x.cpp index ff2a7748bf..8b97b2f499 100644 --- a/esphome/components/sts3x/sts3x.cpp +++ b/esphome/components/sts3x/sts3x.cpp @@ -3,7 +3,7 @@ namespace esphome::sts3x { -static const char *const TAG = "sts3x"; +ESPHOME_LOG_TAG(TAG, "sts3x"); static const uint16_t STS3X_COMMAND_READ_SERIAL_NUMBER = 0x3780; static const uint16_t STS3X_COMMAND_READ_STATUS = 0xF32D; diff --git a/esphome/components/stts22h/__init__.py b/esphome/components/stts22h/__init__.py index a33c0b554b..15a864ef0c 100644 --- a/esphome/components/stts22h/__init__.py +++ b/esphome/components/stts22h/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@B48D81EFCC"] +DOMAIN = "stts22h" diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp index 2b2559c843..7d66dd0a18 100644 --- a/esphome/components/stts22h/stts22h.cpp +++ b/esphome/components/stts22h/stts22h.cpp @@ -3,7 +3,7 @@ namespace esphome::stts22h { -static const char *const TAG = "stts22h"; +ESPHOME_LOG_TAG(TAG, "stts22h"); static const uint8_t WHOAMI_REG = 0x01; static const uint8_t CTRL_REG = 0x04; diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index 5ef7a699eb..711dd9e815 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -25,6 +25,7 @@ from esphome.yaml_util import ( from .jinja import Jinja, JinjaError, Missing, Resolver, UndefinedError, has_jinja CODEOWNERS = ["@esphome/core"] +DOMAIN = "substitutions" _LOGGER = logging.getLogger(__name__) ContextVars = ChainMap[str, Any] @@ -67,8 +68,9 @@ def raise_first_undefined( for e, p_path, _ in errors[1:] ) _LOGGER.debug("Additional undefined variables in %s: %s", context_label, extras) - raise cv.Invalid( - f"Undefined variable in {context_label}: {err.message}\n{format_path(err_path, err_value)}" + raise cv.SourceTraceInvalid( + f"Undefined variable in {context_label}: {err.message}", + format_path(err_path, err_value), ) @@ -222,13 +224,13 @@ def _expand_substitutions( errors=errors, ) except JinjaError as err: - raise cv.Invalid( + raise cv.SourceTraceInvalid( f"{err.error_name()} Error evaluating jinja expression" f" '{value}': {str(err.parent())}." f"\nEvaluation stack: (most recent evaluation last)" f"\n{err.stack_trace_str()}" - f"\nRelevant context:\n{err.context_trace_str()}" - f"\n{format_path(path, orig_value)}", + f"\nRelevant context:\n{err.context_trace_str()}", + format_path(path, orig_value), path, ) from err else: @@ -353,7 +355,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 +364,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,12 +387,12 @@ 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}" - f"\n{format_path(path, original)}", + raise cv.SourceTraceInvalid( + f"Error including file '{filename}'{resolved}: {err}", + format_path(path, original), path + [f"<{filename}>"], ) from err @@ -504,7 +519,7 @@ def resolve_substitutions_block( """Resolve a deferred ``substitutions: !include file.yaml`` and validate the shape. The caller is responsible for wrapping the call in - ``cv.prepend_path(CONF_SUBSTITUTIONS)`` for error reporting. + ``cv.prepend_path(DOMAIN)`` for error reporting. ``command_line_substitutions`` seeds the filename context so ``substitutions: !include ${var}.yaml`` can reference CLI-provided vars. """ @@ -539,7 +554,7 @@ def do_substitution_pass( # Extract substitutions from config, overriding with substitutions coming from command line: # Use merge_dicts_ordered to preserve OrderedDict type for move_to_end() substitutions = config.pop(CONF_SUBSTITUTIONS, {}) - with cv.prepend_path(CONF_SUBSTITUTIONS): + with cv.prepend_path(DOMAIN): substitutions = resolve_substitutions_block( substitutions, command_line_substitutions ) diff --git a/esphome/components/sun/__init__.py b/esphome/components/sun/__init__.py index 33a5c677bd..71441e9782 100644 --- a/esphome/components/sun/__init__.py +++ b/esphome/components/sun/__init__.py @@ -13,18 +13,16 @@ 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"] +DOMAIN = "sun" sun_ns = cg.esphome_ns.namespace("sun") 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 +140,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/sensor/sun_sensor.cpp b/esphome/components/sun/sensor/sun_sensor.cpp index d788e19ea4..b3cdacddae 100644 --- a/esphome/components/sun/sensor/sun_sensor.cpp +++ b/esphome/components/sun/sensor/sun_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::sun { -static const char *const TAG = "sun.sensor"; +ESPHOME_LOG_TAG(TAG, "sun.sensor"); void SunSensor::dump_config() { LOG_SENSOR("", "Sun Sensor", this); } diff --git a/esphome/components/sun/sun.cpp b/esphome/components/sun/sun.cpp index d03ff07981..f4c9d830b4 100644 --- a/esphome/components/sun/sun.cpp +++ b/esphome/components/sun/sun.cpp @@ -16,7 +16,7 @@ namespace esphome::sun { using namespace esphome::sun::internal; -static const char *const TAG = "sun"; +ESPHOME_LOG_TAG(TAG, "sun"); #undef degrees #undef radians 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/text_sensor/sun_text_sensor.cpp b/esphome/components/sun/text_sensor/sun_text_sensor.cpp index 21aa4b86e0..15a3fc21be 100644 --- a/esphome/components/sun/text_sensor/sun_text_sensor.cpp +++ b/esphome/components/sun/text_sensor/sun_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::sun { -static const char *const TAG = "sun.text_sensor"; +ESPHOME_LOG_TAG(TAG, "sun.text_sensor"); void SunTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sun Text Sensor", this); } diff --git a/esphome/components/sun_gtil2/__init__.py b/esphome/components/sun_gtil2/__init__.py index 0f5ae27753..61643cf488 100644 --- a/esphome/components/sun_gtil2/__init__.py +++ b/esphome/components/sun_gtil2/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@Mat931"] +DOMAIN = "sun_gtil2" MULTI_CONF = True DEPENDENCIES = ["uart"] 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/sun_gtil2.cpp b/esphome/components/sun_gtil2/sun_gtil2.cpp index 78e398d086..f89797cc10 100644 --- a/esphome/components/sun_gtil2/sun_gtil2.cpp +++ b/esphome/components/sun_gtil2/sun_gtil2.cpp @@ -3,7 +3,7 @@ namespace esphome::sun_gtil2 { -static const char *const TAG = "sun_gtil2"; +ESPHOME_LOG_TAG(TAG, "sun_gtil2"); static const double NTC_A = 0.0011591051055979914; static const double NTC_B = 0.00022878183547845582; 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 d6cd061e04..e965b552f4 100644 --- a/esphome/components/switch/__init__.py +++ b/esphome/components/switch/__init__.py @@ -20,16 +20,19 @@ 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"] +DOMAIN = "switch" IS_PLATFORM_COMPONENT = True DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, @@ -173,6 +176,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), diff --git a/esphome/components/switch/automation.h b/esphome/components/switch/automation.h deleted file mode 100644 index 8c7a3a725b..0000000000 --- a/esphome/components/switch/automation.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "esphome/components/switch/switch.h" - -namespace esphome::switch_ { - -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(); - } - }); - } -}; - -} // namespace esphome::switch_ diff --git a/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp b/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp index 19995fb1ae..c5b93e1dbb 100644 --- a/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp +++ b/esphome/components/switch/binary_sensor/switch_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::switch_ { -static const char *const TAG = "switch.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "switch.binary_sensor"); void SwitchBinarySensor::setup() { source_->add_on_state_callback([this](bool value) { this->publish_state(value); }); diff --git a/esphome/components/switch/switch.cpp b/esphome/components/switch/switch.cpp index 57e4f222bc..179ef2ce35 100644 --- a/esphome/components/switch/switch.cpp +++ b/esphome/components/switch/switch.cpp @@ -5,7 +5,7 @@ namespace esphome::switch_ { -static const char *const TAG = "switch"; +ESPHOME_LOG_TAG(TAG, "switch"); Switch::Switch() : state(false) {} @@ -57,6 +57,7 @@ void Switch::publish_state(bool state) { if (!this->publish_dedup_.next(state)) return; this->state = state != this->inverted_; + this->set_has_state(true); if (restore_mode & RESTORE_MODE_PERSISTENT_MASK) this->rtc_.save(&this->state); diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 29e3ad5359..c8b920d6cb 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -10,6 +10,7 @@ from esphome.core import ID, TimePeriod from esphome.cpp_generator import MockObj from esphome.types import ConfigType, TemplateArgsType +DOMAIN = "sx126x" MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] DEPENDENCIES = ["spi"] @@ -131,24 +132,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 +147,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 +211,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 +295,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 +312,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 +342,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/packet_transport/sx126x_transport.cpp b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp index 5e992cc731..70dc818495 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp @@ -4,7 +4,7 @@ namespace esphome::sx126x { -static const char *const TAG = "sx126x_transport"; +ESPHOME_LOG_TAG(TAG, "sx126x_transport"); void SX126xTransport::setup() { PacketTransport::setup(); diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index 376676ce85..6c1281c89b 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -4,7 +4,7 @@ namespace esphome::sx126x { -static const char *const TAG = "sx126x"; +ESPHOME_LOG_TAG(TAG, "sx126x"); static const uint16_t RAMP[8] = {10, 20, 40, 80, 200, 800, 1700, 3400}; static const uint32_t BW_HZ[31] = {4800, 5800, 7300, 9700, 11700, 14600, 19500, 23400, 29300, 39000, 46900, 58600, 78200, 93800, 117300, 156200, 187200, 234300, 312000, 373600, 467000, 7810, @@ -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..ea7fe38ba3 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -10,6 +10,7 @@ from esphome.core import ID from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType +DOMAIN = "sx127x" MULTI_CONF = True CODEOWNERS = ["@swoboda1337"] DEPENDENCIES = ["spi"] @@ -120,24 +121,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 +136,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 +204,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 +279,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 +314,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/packet_transport/sx127x_transport.cpp b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp index 52d5631791..77d1ea2880 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp @@ -4,7 +4,7 @@ namespace esphome::sx127x { -static const char *const TAG = "sx127x_transport"; +ESPHOME_LOG_TAG(TAG, "sx127x_transport"); void SX127xTransport::setup() { PacketTransport::setup(); diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index cd81f08914..16c3d1dd21 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -4,7 +4,7 @@ namespace esphome::sx127x { -static const char *const TAG = "sx127x"; +ESPHOME_LOG_TAG(TAG, "sx127x"); static const uint32_t FXOSC = 32000000u; static const uint16_t RAMP[16] = {3400, 2000, 1000, 500, 250, 125, 100, 62, 50, 40, 31, 25, 20, 15, 12, 10}; static const uint32_t BW_HZ[22] = {2604, 3125, 3906, 5208, 6250, 7812, 10416, 12500, 15625, 20833, 25000, @@ -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..f96479498d 100644 --- a/esphome/components/sx1509/__init__.py +++ b/esphome/components/sx1509/__init__.py @@ -29,6 +29,7 @@ CONF_SX1509_ID = "sx1509_id" AUTO_LOAD = ["key_provider", "gpio_expander"] DEPENDENCIES = ["i2c"] +DOMAIN = "sx1509" MULTI_CONF = True sx1509_ns = cg.esphome_ns.namespace("sx1509") @@ -43,6 +44,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 +103,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 +134,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/output/sx1509_float_output.cpp b/esphome/components/sx1509/output/sx1509_float_output.cpp index 528de1fde5..5bf47e97bd 100644 --- a/esphome/components/sx1509/output/sx1509_float_output.cpp +++ b/esphome/components/sx1509/output/sx1509_float_output.cpp @@ -4,7 +4,7 @@ namespace esphome::sx1509 { -static const char *const TAG = "sx1509_float_channel"; +ESPHOME_LOG_TAG(TAG, "sx1509_float_channel"); void SX1509FloatOutputChannel::write_state(float state) { const uint16_t max_duty = 255; diff --git a/esphome/components/sx1509/sx1509.cpp b/esphome/components/sx1509/sx1509.cpp index 2397049000..169d35331f 100644 --- a/esphome/components/sx1509/sx1509.cpp +++ b/esphome/components/sx1509/sx1509.cpp @@ -4,7 +4,7 @@ namespace esphome::sx1509 { -static const char *const TAG = "sx1509"; +ESPHOME_LOG_TAG(TAG, "sx1509"); void SX1509Component::setup() { ESP_LOGV(TAG, " Resetting devices"); @@ -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/sx1509/sx1509_gpio_pin.cpp b/esphome/components/sx1509/sx1509_gpio_pin.cpp index 28ef1c5830..41f967b667 100644 --- a/esphome/components/sx1509/sx1509_gpio_pin.cpp +++ b/esphome/components/sx1509/sx1509_gpio_pin.cpp @@ -5,7 +5,7 @@ namespace esphome::sx1509 { -static const char *const TAG = "sx1509_gpio_pin"; +ESPHOME_LOG_TAG(TAG, "sx1509_gpio_pin"); void SX1509GPIOPin::setup() { pin_mode(flags_); } void SX1509GPIOPin::pin_mode(gpio::Flags flags) { this->parent_->pin_mode(this->pin_, flags); } diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index cb9d64aee7..90bd9f4691 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@linkedupbits"] DEPENDENCIES = ["i2c"] +DOMAIN = "sy6970" MULTI_CONF = True CONF_SY6970_ID = "sy6970_id" 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/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 1f1648cfa7..3f381002cb 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -4,7 +4,7 @@ namespace esphome::sy6970 { -static const char *const TAG = "sy6970"; +ESPHOME_LOG_TAG(TAG, "sy6970"); bool SY6970Component::read_all_registers_() { // Read all registers from 0x00 to 0x14 in one transaction (21 bytes) 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 e25567c783..b48192ddd2 100644 --- a/esphome/components/syslog/__init__.py +++ b/esphome/components/syslog/__init__.py @@ -9,6 +9,7 @@ from esphome.cpp_types import Component, Parented from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] +DOMAIN = "syslog" DEPENDENCIES = ["udp", "logger", "time"] diff --git a/esphome/components/systa_bus/__init__.py b/esphome/components/systa_bus/__init__.py new file mode 100644 index 0000000000..ae44017112 --- /dev/null +++ b/esphome/components/systa_bus/__init__.py @@ -0,0 +1,44 @@ +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"] +DOMAIN = "systa_bus" + +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" + +CONF_SYSTASOLAR_AQUA = "systasolar_aqua" + +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..d3223484ff --- /dev/null +++ b/esphome/components/systa_bus/sensor/__init__.py @@ -0,0 +1,89 @@ +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, + CONF_SYSTASOLAR_AQUA, + SystaBus, + register_systa_bus_listener, + systa_bus_ns, +) + +SystaSolarAquaSensor = systa_bus_ns.class_("SystaSolarAquaSensor", cg.Component) + +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 +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) + + # The schema only lets a model's own keys through, so unconfigured ones are skipped here + sensors = sensor.sub_sensors(config) + await sensors(CONF_TEMPERATURE_TSA, var.set_temperature_tsa_sensor) + await sensors(CONF_TEMPERATURE_TSE, var.set_temperature_tse_sensor) + await sensors(CONF_TEMPERATURE_TWU, var.set_temperature_twu_sensor) + await sensors(CONF_TEMPERATURE_TW2, var.set_temperature_tw2_sensor) + await sensors(CONF_PUMP_SPEED, var.set_pump_speed_sensor) + + 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..5d9890eca5 --- /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 { + +ESPHOME_LOG_TAG(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..1b135c9666 --- /dev/null +++ b/esphome/components/systa_bus/sensor/systa_bus_sensor.h @@ -0,0 +1,22 @@ +#pragma once + +#include "../systa_bus.h" +#include "esphome/components/sensor/sensor.h" + +#include + +namespace esphome::systa_bus { + +class SystaSolarAquaSensor : public SystaBusListener, public Component { + SUB_SENSOR(temperature_tsa) + SUB_SENSOR(temperature_tse) + SUB_SENSOR(temperature_twu) + SUB_SENSOR(temperature_tw2) + SUB_SENSOR(pump_speed) + + public: + void dump_config() override; + void handle_message(std::span message) override; +}; + +} // 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..6c780d07a9 --- /dev/null +++ b/esphome/components/systa_bus/systa_bus.cpp @@ -0,0 +1,65 @@ +#include "systa_bus.h" +#include "esphome/core/log.h" + +#include +#include + +namespace esphome::systa_bus { + +ESPHOME_LOG_TAG(TAG, "systa_bus"); + +void SystaBus::dump_config() { ESP_LOGCONFIG(TAG, "SystaBus:"); } + +static bool checksum(std::span data) { return sum_bytes(data.data(), data.size()) == 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..b46f769db0 --- /dev/null +++ b/esphome/components/systa_bus/systa_bus.h @@ -0,0 +1,51 @@ +#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..14] not decoded, +// [15] error code (zero for no error), [16..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/systa_bus/text_sensor/__init__.py b/esphome/components/systa_bus/text_sensor/__init__.py new file mode 100644 index 0000000000..4b5cb52b08 --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_MODEL +from esphome.types import ConfigType + +from .. import ( + CONF_SYSTA_BUS_ID, + CONF_SYSTASOLAR_AQUA, + SystaBus, + register_systa_bus_listener, + systa_bus_ns, +) + +SystaSolarAquaTextSensor = systa_bus_ns.class_("SystaSolarAquaTextSensor", cg.Component) + +CONF_ERROR_CODE = "error_code" + + +CONFIG_SCHEMA = cv.typed_schema( + { + CONF_SYSTASOLAR_AQUA: cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(SystaSolarAquaTextSensor), + cv.GenerateID(CONF_SYSTA_BUS_ID): cv.use_id(SystaBus), + cv.Optional(CONF_ERROR_CODE): text_sensor.text_sensor_schema(), + } + ), + }, + 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) + + if config[CONF_MODEL] == CONF_SYSTASOLAR_AQUA and CONF_ERROR_CODE in config: + sens = await text_sensor.new_text_sensor(config[CONF_ERROR_CODE]) + cg.add(var.set_error_code_text_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/text_sensor/systa_bus_text_sensor.cpp b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp new file mode 100644 index 0000000000..cba3caa8a6 --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.cpp @@ -0,0 +1,27 @@ +#include "systa_bus_text_sensor.h" +#include "esphome/core/log.h" + +namespace esphome::systa_bus { + +ESPHOME_LOG_TAG(TAG, "systa_bus.text_sensor"); + +void SystaSolarAquaTextSensor::dump_config() { + ESP_LOGCONFIG(TAG, "SystaSolar Aqua:"); + LOG_TEXT_SENSOR(" ", "Error code", this->error_code_text_sensor_); +} + +void SystaSolarAquaTextSensor::handle_message(std::span message) { + if (get_message_type(message) == MESSAGE_TYPE_AQUA_SENSOR_DATA) { + if (this->error_code_text_sensor_ != nullptr) { + if (message[15] == 0) { + this->error_code_text_sensor_->publish_state("--"); + } else { + char buf[4]; + int len = snprintf(buf, sizeof(buf), "%02d", message[15]); + this->error_code_text_sensor_->publish_state(buf, static_cast(len)); + } + } + } +} + +} // namespace esphome::systa_bus diff --git a/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h new file mode 100644 index 0000000000..f0c12b5dce --- /dev/null +++ b/esphome/components/systa_bus/text_sensor/systa_bus_text_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "../systa_bus.h" +#include "esphome/components/text_sensor/text_sensor.h" + +#include + +namespace esphome::systa_bus { + +class SystaSolarAquaTextSensor : public SystaBusListener, public Component { + public: + void dump_config() override; + void set_error_code_text_sensor(text_sensor::TextSensor *sensor) { this->error_code_text_sensor_ = sensor; } + void handle_message(std::span message) override; + + protected: + text_sensor::TextSensor *error_code_text_sensor_{nullptr}; +}; + +} // namespace esphome::systa_bus diff --git a/esphome/components/t6615/__init__.py b/esphome/components/t6615/__init__.py index e69de29bb2..bbbca72b13 100644 --- a/esphome/components/t6615/__init__.py +++ b/esphome/components/t6615/__init__.py @@ -0,0 +1 @@ +DOMAIN = "t6615" 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/t6615/t6615.cpp b/esphome/components/t6615/t6615.cpp index 982cc181b7..d399752889 100644 --- a/esphome/components/t6615/t6615.cpp +++ b/esphome/components/t6615/t6615.cpp @@ -4,7 +4,7 @@ namespace esphome::t6615 { -static const char *const TAG = "t6615"; +ESPHOME_LOG_TAG(TAG, "t6615"); static const uint32_t T6615_TIMEOUT = 1000; static const uint8_t T6615_MAGIC = 0xFF; diff --git a/esphome/components/tas2780/__init__.py b/esphome/components/tas2780/__init__.py new file mode 100644 index 0000000000..f1fed4b698 --- /dev/null +++ b/esphome/components/tas2780/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@remcom"] +DOMAIN = "tas2780" 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..d69bd4dca1 --- /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 { + +ESPHOME_LOG_TAG(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..56700aeafd --- /dev/null +++ b/esphome/components/tas58xx/__init__.py @@ -0,0 +1,11 @@ +from esphome.config_helpers import filter_source_files_from_defines + +CODEOWNERS = ["@mrtoy-me", "@remcom"] +DOMAIN = "tas58xx" + +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..09b369435e --- /dev/null +++ b/esphome/components/tas58xx/model_tas5805m.cpp @@ -0,0 +1,93 @@ +#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}, // Page 1 + {0x51, 0x05}, + {0x00, 0x00}, // Page 0 + + // Additional register configuration + {0x53, 0x60}, // ANA_CTRL: for high audio performance use 175kHz bandwidth with Fsw=768kHz +}; + +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..cf5861cb4a --- /dev/null +++ b/esphome/components/tas58xx/model_tas5825m.cpp @@ -0,0 +1,118 @@ +#include "tas58xx.h" + +#include "esphome/core/hal.h" + +namespace esphome::tas58xx { + +// Remainder of the startup sequence, run after the reset in TAS58xx::init_(). Register 0x00 selects the page. +// Based on the TI PurePath Console (PPC) export for "TAS5825M 2-Band DRC&AGL 2.0 96k". +// Registers 0x46, 0x7D, 0x7E, page 1 register 0x51 and page 2 registers 0x1D, 0x19 are not documented in datasheet. +static const uint8_t STARTUP_SEQUENCE[][2] PROGMEM = { + {0x03, 0x00}, // DEVICE_CTRL_2: deep sleep, the state the PPC sequence starts from + {0x7D, 0x11}, + {0x7E, 0xFF}, + {0x00, 0x01}, // Page 1 + {0x51, 0x05}, + {0x00, 0x02}, // Page 2 + {0x1D, 0x00}, + {0x19, 0x80}, + {0x00, 0x00}, // Page 0 + {0x46, 0x11}, + // PPC also writes 0x02 (DEVICE_CTRL_1) and 0x54 (AGAIN) here; init_() writes both from YAML + {0x53, 0x01}, // ANA_CTRL: in phase, 100kHz bandwidth for high audio performance with Fsw=384kHz + {0x03, 0x02}, // DEVICE_CTRL_2: Hi-Z + + // Additional register configuration + // GPIO1 is a dedicated pin on TAS5825M (separate from ADR), used as FAULTZ + {0x62, 0x0B}, // GPIO1_SEL: GPIO1 as FAULTZ + {0x60, 0x02}, // GPIO_CTRL: GPIO1 is output + {0x77, 0x07}, // CBC_CONTROL: enable cycle-by-cycle current limit for warnings and faults +}; + +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..3addbfda00 --- /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 { + +ESPHOME_LOG_TAG(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/tc74/__init__.py b/esphome/components/tc74/__init__.py index c1407c7cad..1021a425b8 100644 --- a/esphome/components/tc74/__init__.py +++ b/esphome/components/tc74/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@sethgirvan"] +DOMAIN = "tc74" diff --git a/esphome/components/tc74/tc74.cpp b/esphome/components/tc74/tc74.cpp index bc522d1b74..98428cd122 100644 --- a/esphome/components/tc74/tc74.cpp +++ b/esphome/components/tc74/tc74.cpp @@ -5,7 +5,7 @@ namespace esphome::tc74 { -static const char *const TAG = "tc74"; +ESPHOME_LOG_TAG(TAG, "tc74"); static const uint8_t TC74_REGISTER_TEMPERATURE = 0x00; static const uint8_t TC74_REGISTER_CONFIGURATION = 0x01; diff --git a/esphome/components/tca8418/__init__.py b/esphome/components/tca8418/__init__.py new file mode 100644 index 0000000000..67b38dc21e --- /dev/null +++ b/esphome/components/tca8418/__init__.py @@ -0,0 +1,156 @@ +import logging + +from esphome import automation +import esphome.codegen as cg +from esphome.components import gpio_expander, i2c, key_provider +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_INTERRUPT_PIN, CONF_ON_KEY +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@zebble"] +DEPENDENCIES = ["i2c"] +DOMAIN = "tca8418" +AUTO_LOAD = ["key_provider", "gpio_expander"] +MULTI_CONF = True + +# Each keypad keeps its listeners in a fixed size list; the define is the largest count any one keypad needs +_request_listener_slot = cg.slot_counter("TCA8418_LISTENER_COUNT") + + +async def register_listener(keypad: cg.MockObj, var: cg.MockObj) -> None: + _request_listener_slot(str(keypad)) + cg.add(keypad.register_listener(var)) + + +CONF_GPI_EVENTS = "gpi_events" +CONF_KEYPAD_ID = "keypad_id" + +# Matrix keys are numbered row by row from 1 with ten columns per row; pins outside +# the matrix report as individual inputs 97..114 (ROW0..7, then COL0..9) +MATRIX_COLUMNS = 10 +MATRIX_KEY_MAX = 80 +GPI_KEY_MIN = 97 +GPI_KEY_MAX = 114 + + +def _matrix_key(row: int, col: int) -> int: + return row * MATRIX_COLUMNS + col + 1 + + +tca8418_ns = cg.esphome_ns.namespace("tca8418") +TCA8418Component = tca8418_ns.class_( + "TCA8418Component", key_provider.KeyProvider, cg.Component, i2c.I2CDevice +) + + +def _ascii_string(value: str) -> str: + """A string of single byte characters, so it lines up with the matrix.""" + value = cv.string_strict(value) + if not value.isascii(): + raise cv.Invalid( + "Only ASCII characters can be used, since each key is one character" + ) + return value + + +def _validate(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS, 0) + columns = config.get(CONF_COLUMNS, 0) + if (keys := config.get(CONF_KEYS)) is not None: + if rows == 0: + raise cv.Invalid( + "'keys' maps the key matrix, so 'rows' and 'columns' are required", + path=[CONF_KEYS], + ) + if len(keys) != rows * columns: + raise cv.Invalid( + f"'keys' must have exactly {rows * columns} characters " + f"({rows} rows x {columns} columns), got {len(keys)}", + path=[CONF_KEYS], + ) + # Individual inputs report as 97..114, which are also the codes for the + # letters 'a' to 'r'. Both reach on_key and key collectors as a number. + if ( + keys is not None + and config[CONF_GPI_EVENTS] + and ( + clashing := sorted( + {k for k in keys if GPI_KEY_MIN <= ord(k) <= GPI_KEY_MAX} + ) + ) + ): + _LOGGER.warning( + "In '%s', the key(s) %s cannot be told apart from an individual input " + "on the '%s' and key collector paths, because both are reported as the " + "same number. Binary sensors are unaffected. Use other characters, or " + "set '%s: false', if this matters.", + CONF_KEYS, + ", ".join(f"'{k}'" for k in clashing), + CONF_ON_KEY, + CONF_GPI_EVENTS, + ) + if rows == 0 and not config[CONF_GPI_EVENTS]: + raise cv.Invalid( + "Nothing to report: set 'rows' and 'columns' for a key matrix, " + "or leave 'gpi_events' enabled to report individual inputs" + ) + return config + + +_MATRIX_MSG = ( + "'rows' and 'columns' must both be set to use the key matrix, " + "or both left unset to use every pin as an individual input" +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TCA8418Component), + # The key matrix occupies ROW0..ROW(rows-1) and COL0..COL(columns-1). + # Leave both unset to use every pin as an individual input instead. + cv.Inclusive(CONF_ROWS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=8 + ), + cv.Inclusive(CONF_COLUMNS, "matrix", msg=_MATRIX_MSG): cv.int_range( + min=1, max=10 + ), + cv.Optional(CONF_KEYS): _ascii_string, + # Report pins that are not part of the matrix as individual inputs. + cv.Optional(CONF_GPI_EVENTS, default=True): cv.boolean, + cv.Optional(CONF_INTERRUPT_PIN): gpio_expander.validate_interrupt_pin, + cv.Optional(CONF_ON_KEY): automation.validate_automation({}), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(0x34)), + _validate, +) + + +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) + + if (rows := config.get(CONF_ROWS)) is not None: + columns = config[CONF_COLUMNS] + cg.add(var.set_rows(rows)) + cg.add(var.set_columns(columns)) + if (keys := config.get(CONF_KEYS)) is not None: + # Indexed by key number - 1, with 0 where no key is assigned. + table = [0] * _matrix_key(rows - 1, columns - 1) + for index, char in enumerate(keys.encode()): + table[_matrix_key(*divmod(index, columns)) - 1] = char + array = cg.shared_progmem_array("tca8418_keys", cg.uint8, table) + cg.add(var.set_keys(array, len(table))) + cg.add(var.set_gpi_events(config[CONF_GPI_EVENTS])) + if (interrupt_pin := config.get(CONF_INTERRUPT_PIN)) is not None: + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) + + for conf in config.get(CONF_ON_KEY, []): + await automation.build_callback_automation( + var, "add_on_key_callback", [(cg.uint8, "x")], conf + ) diff --git a/esphome/components/tca8418/binary_sensor/__init__.py b/esphome/components/tca8418/binary_sensor/__init__.py new file mode 100644 index 0000000000..3e1e766c57 --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/__init__.py @@ -0,0 +1,143 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +from esphome.components.const import CONF_COLUMNS, CONF_KEYS, CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_COL, CONF_KEY, CONF_ROW +import esphome.final_validate as fv +from esphome.types import ConfigType + +from .. import ( + CONF_GPI_EVENTS, + CONF_KEYPAD_ID, + GPI_KEY_MAX, + GPI_KEY_MIN, + MATRIX_COLUMNS, + MATRIX_KEY_MAX, + TCA8418Component, + _ascii_string, + _matrix_key, + register_listener, + tca8418_ns, +) + +DEPENDENCIES = ["tca8418"] + +CONF_KEY_CODE = "key_code" + +TCA8418BinarySensor = tca8418_ns.class_( + "TCA8418BinarySensor", binary_sensor.BinarySensorInitiallyOff +) + + +def _key_code(value: int) -> int: + value = cv.int_(value) + if 1 <= value <= MATRIX_KEY_MAX or GPI_KEY_MIN <= value <= GPI_KEY_MAX: + return value + raise cv.Invalid( + f"Key numbers run from 1 to {MATRIX_KEY_MAX} for the key matrix, " + f"and {GPI_KEY_MIN} to {GPI_KEY_MAX} for individual inputs" + ) + + +_POSITION_MSG = f"'{CONF_ROW}' and '{CONF_COL}' must be given together" + +CONFIG_SCHEMA = cv.All( + binary_sensor.binary_sensor_schema(TCA8418BinarySensor).extend( + { + cv.GenerateID(CONF_KEYPAD_ID): cv.use_id(TCA8418Component), + cv.Inclusive(CONF_ROW, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=7 + ), + cv.Inclusive(CONF_COL, "position", msg=_POSITION_MSG): cv.int_range( + min=0, max=9 + ), + cv.Optional(CONF_KEY): cv.All( + cv.string, + _ascii_string, + cv.Length(min=1, max=1, msg="Key must be a single character"), + ), + cv.Optional(CONF_KEY_CODE): _key_code, + } + ), + cv.has_exactly_one_key(CONF_ROW, CONF_KEY, CONF_KEY_CODE), +) + + +def _final_validate(config: ConfigType) -> None: + """Resolve the key to the number the device reports, checked against the keypad's matrix.""" + full_config = fv.full_config.get() + keypad_path = full_config.get_path_for_id(config[CONF_KEYPAD_ID])[:-1] + keypad = full_config.get_config_for_path(keypad_path) + rows = keypad.get(CONF_ROWS, 0) + columns = keypad.get(CONF_COLUMNS, 0) + + if (row := config.get(CONF_ROW)) is not None: + col = config[CONF_COL] + if row >= rows or col >= columns: + raise cv.Invalid( + f"Position {row}/{col} is outside the {rows} x {columns} key " + "matrix of the keypad it belongs to", + path=[CONF_ROW], + ) + config[CONF_KEY_CODE] = _matrix_key(row, col) + return + + if (key_code := config.get(CONF_KEY_CODE)) is not None: + if key_code <= MATRIX_KEY_MAX: + row, col = divmod(key_code - 1, MATRIX_COLUMNS) + if row >= rows or col >= columns: + raise cv.Invalid( + f"Key number {key_code} is position {row}/{col}, which is " + f"outside the {rows} x {columns} key matrix of the keypad it " + "belongs to", + path=[CONF_KEY_CODE], + ) + return + if not keypad[CONF_GPI_EVENTS]: + raise cv.Invalid( + f"Key number {key_code} is an individual input, but the keypad has " + f"'{CONF_GPI_EVENTS}' turned off", + path=[CONF_KEY_CODE], + ) + # 97..104 are ROW0..ROW7 and 105..114 are COL0..COL9. A pin used by the + # matrix cannot also report on its own. + offset = key_code - GPI_KEY_MIN + if offset < 8: + in_matrix, pin = offset < rows, f"ROW{offset}" + else: + in_matrix, pin = offset - 8 < columns, f"COL{offset - 8}" + if in_matrix: + raise cv.Invalid( + f"Key number {key_code} is {pin}, which the key matrix is using, so " + "it cannot report as an individual input", + path=[CONF_KEY_CODE], + ) + return + + if (key := config.get(CONF_KEY)) is not None: + keys = keypad.get(CONF_KEYS) + if keys is None: + raise cv.Invalid( + f"'{CONF_KEY}' names a key from the keypad's 'keys', which is not set", + path=[CONF_KEY], + ) + if (index := keys.find(key)) < 0: + raise cv.Invalid( + f"'{key}' is not one of the keypad's keys", path=[CONF_KEY] + ) + if keys.count(key) > 1: + raise cv.Invalid( + f"'{key}' appears more than once in the keypad's keys, so it does " + f"not name one key. Use '{CONF_ROW}' and '{CONF_COL}' instead", + path=[CONF_KEY], + ) + config[CONF_KEY_CODE] = _matrix_key(*divmod(index, columns)) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + var = await binary_sensor.new_binary_sensor(config, config[CONF_KEY_CODE]) + keypad = await cg.get_variable(config[CONF_KEYPAD_ID]) + await register_listener(keypad, var) diff --git a/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h new file mode 100644 index 0000000000..d6d58b93bc --- /dev/null +++ b/esphome/components/tca8418/binary_sensor/tca8418_binary_sensor.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "../tca8418.h" + +namespace esphome::tca8418 { + +/// One key of a TCA8418, matched by the key number codegen resolved from the config +class TCA8418BinarySensor : public binary_sensor::BinarySensorInitiallyOff, public TCA8418Listener { + public: + explicit TCA8418BinarySensor(uint8_t key) : key_(key) {} + + void key_pressed(uint8_t key) override { + if (key == this->key_) + this->publish_state(true); + } + void key_released(uint8_t key) override { + if (key == this->key_) + this->publish_state(false); + } + + protected: + const uint8_t key_; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.cpp b/esphome/components/tca8418/tca8418.cpp new file mode 100644 index 0000000000..7add67e9b0 --- /dev/null +++ b/esphome/components/tca8418/tca8418.cpp @@ -0,0 +1,170 @@ +#include "tca8418.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +namespace esphome::tca8418 { + +ESPHOME_LOG_TAG(TAG, "tca8418"); + +// Minimum gap between event queue reads; the ten entry queue holds anything arriving sooner. +static constexpr uint32_t POLL_INTERVAL_MS = 50; + +void IRAM_ATTR TCA8418Component::interrupt_handler(TCA8418Component *arg) { arg->enable_loop_soon_any_context(); } + +void TCA8418Component::setup() { + // The configuration register reads back, so use it to check the device is there. + uint8_t config; + if (!this->read_byte(TCA8418_REG_CFG, &config)) { + ESP_LOGE(TAG, "Failed to read from device - check wiring / address"); + this->mark_failed(); + return; + } + + if (!this->configure_pins_()) { + ESP_LOGE(TAG, "Failed to configure pins"); + this->mark_failed(); + return; + } + + // Report key events, and keep the interrupt asserted while events remain queued. + if (!this->write_byte(TCA8418_REG_CFG, TCA8418_CFG_KEY_INT_EN | TCA8418_CFG_INT_CFG)) { + ESP_LOGE(TAG, "Failed to write the configuration register"); + this->mark_failed(); + return; + } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + // The interrupt output is active low, so a falling edge means an event was + // queued. The handler only wakes the loop; the queue is read from there. + this->interrupt_pin_->attach_interrupt(&TCA8418Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE); + } + + // Discard anything the device queued before it was configured, and clear + // every interrupt flag: leaving one set holds the interrupt output asserted. + if (!this->drain_events_(false)) { + ESP_LOGE(TAG, "Failed to clear the event queue"); + this->mark_failed(); + return; + } + + // With an interrupt pin there is nothing to do until it fires. Stay running + // if the device still has something queued, since that produces no new edge. + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::configure_pins_() { + // Each of the three registers covers a group of pins: ROW0-7, COL0-7, COL8-9. + uint8_t matrix[3] = {0, 0, 0}; + if (this->rows_ > 0) { + matrix[0] = static_cast((1U << this->rows_) - 1U); + const uint8_t low_columns = this->columns_ > 8 ? 8 : this->columns_; + matrix[1] = static_cast((1U << low_columns) - 1U); + if (this->columns_ > 8) + matrix[2] = static_cast((1U << (this->columns_ - 8U)) - 1U); + } + + // Pins outside the matrix report individually when they are in event mode. + // The device ignores these bits for pins that belong to the matrix. + const uint8_t gpi[3] = { + static_cast(this->gpi_events_ ? ~matrix[0] : 0), + static_cast(this->gpi_events_ ? ~matrix[1] : 0), + static_cast(this->gpi_events_ ? (0x03 & ~matrix[2]) : 0), + }; + + for (uint8_t i = 0; i < 3; i++) { + // Enable the internal pull-ups (a 0 bit enables the pull-up) so that + // buttons can simply switch their pin to ground. + if (!this->write_byte(TCA8418_REG_GPIO_PULL1 + i, 0x00) || !this->write_byte(TCA8418_REG_KP_GPIO1 + i, matrix[i]) || + !this->write_byte(TCA8418_REG_GPI_EM1 + i, gpi[i]) || !this->write_byte(TCA8418_REG_GPIO_INT_EN1 + i, gpi[i])) { + return false; + } + } + return true; +} + +void TCA8418Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_poll_ < POLL_INTERVAL_MS) + return; + this->last_poll_ = now; + + if (!this->drain_events_(true)) { + this->status_set_warning(); + return; + } + this->status_clear_warning(); + + // Events that arrive during a read keep INT asserted without a new edge, so only stop once it is clear + if (this->interrupt_pin_ != nullptr && this->interrupt_pin_->digital_read()) + this->disable_loop(); +} + +bool TCA8418Component::drain_events_(bool dispatch) { + uint8_t count; + if (!this->read_byte(TCA8418_REG_KEY_LCK_EC, &count)) + return false; + count &= TCA8418_EVENT_COUNT_MASK; + // An idle poll has no flags to clear, unless INT is still asserted from a failed clear + if (count == 0 && dispatch && (this->interrupt_pin_ == nullptr || this->interrupt_pin_->digital_read())) + return true; + // The overflow flag stays clear with a full queue, so check the count + if (dispatch && count >= TCA8418_FIFO_DEPTH) { + ESP_LOGW(TAG, "Event queue is full - some key presses may have been lost"); + } + + uint8_t event; + for (uint8_t i = 0; i < count; i++) { + if (!this->read_byte(TCA8418_REG_KEY_EVENT_A, &event)) + return false; + if (dispatch) + this->dispatch_(event & TCA8418_KEY_CODE_MASK, (event & TCA8418_KEY_PRESSED) != 0); + } + return this->write_byte(TCA8418_REG_INT_STAT, TCA8418_INT_STAT_ALL); +} + +uint8_t TCA8418Component::key_char_(uint8_t key) const { + if (key == 0 || key > this->keys_size_) + return 0; + return progmem_read_byte(&this->keys_[key - 1]); +} + +void TCA8418Component::dispatch_(uint8_t key, bool pressed) { + ESP_LOGV(TAG, "Key %u %s", key, pressed ? LOG_STR_LITERAL("pressed") : LOG_STR_LITERAL("released")); + +#ifdef TCA8418_LISTENER_COUNT + for (auto *listener : this->listeners_) { + if (pressed) { + listener->key_pressed(key); + } else { + listener->key_released(key); + } + } +#endif + + if (!pressed) + return; + + // Key collectors and automations receive the mapped character when a key map + // is configured, and the key number otherwise. + const uint8_t key_char = this->key_char_(key); + this->send_key_(key_char != 0 ? key_char : key); +} + +void TCA8418Component::dump_config() { + ESP_LOGCONFIG(TAG, + "TCA8418:\n" + " Individual inputs: %s", + YESNO(this->gpi_events_)); + if (this->rows_ > 0) { + ESP_LOGCONFIG(TAG, " Key matrix: %u rows x %u columns", this->rows_, this->columns_); + } + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca8418/tca8418.h b/esphome/components/tca8418/tca8418.h new file mode 100644 index 0000000000..383182c1e0 --- /dev/null +++ b/esphome/components/tca8418/tca8418.h @@ -0,0 +1,85 @@ +#pragma once + +#include "esphome/components/key_provider/key_provider.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tca8418 { + +// Register map (see TI datasheet SCPS231) +static constexpr uint8_t TCA8418_REG_CFG = 0x01; +static constexpr uint8_t TCA8418_REG_INT_STAT = 0x02; +static constexpr uint8_t TCA8418_REG_KEY_LCK_EC = 0x03; +static constexpr uint8_t TCA8418_REG_KEY_EVENT_A = 0x04; // top of the event FIFO +static constexpr uint8_t TCA8418_REG_GPIO_INT_EN1 = 0x1A; +static constexpr uint8_t TCA8418_REG_KP_GPIO1 = 0x1D; +static constexpr uint8_t TCA8418_REG_GPI_EM1 = 0x20; +static constexpr uint8_t TCA8418_REG_GPIO_PULL1 = 0x2C; + +// CFG +static constexpr uint8_t TCA8418_CFG_KEY_INT_EN = 0x01; // interrupt on key events +static constexpr uint8_t TCA8418_CFG_INT_CFG = 0x10; // re-assert INT while events remain +// INT_STAT +static constexpr uint8_t TCA8418_INT_STAT_ALL = 0x1F; // every interrupt flag +// KEY_LCK_EC: the low nibble is the number of queued events +static constexpr uint8_t TCA8418_EVENT_COUNT_MASK = 0x0F; +// Key events: bit 7 set = pressed, clear = released; bits 6-0 are the key number +static constexpr uint8_t TCA8418_KEY_PRESSED = 0x80; +static constexpr uint8_t TCA8418_KEY_CODE_MASK = 0x7F; +// The event queue holds ten entries, which bounds how many can be read at once. +static constexpr uint8_t TCA8418_FIFO_DEPTH = 10; + +class TCA8418Listener { + public: + /// Called with the key number reported by the device (1..80 matrix, 97..114 individual inputs). + virtual void key_pressed(uint8_t key) {} + virtual void key_released(uint8_t key) {} +}; + +class TCA8418Component : public key_provider::KeyProvider, public Component, public i2c::I2CDevice { + public: + void setup() override; + void loop() override; + void dump_config() override; + + void set_rows(uint8_t rows) { this->rows_ = rows; } + void set_columns(uint8_t columns) { this->columns_ = columns; } + void set_gpi_events(bool gpi_events) { this->gpi_events_ = gpi_events; } + void set_keys(const uint8_t *keys, uint8_t size) { + this->keys_ = keys; + this->keys_size_ = size; + } + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + +#ifdef TCA8418_LISTENER_COUNT + void register_listener(TCA8418Listener *listener) { this->listeners_.push_back(listener); } +#endif + + /// Wakes the loop so queued events are read. Runs in interrupt context. + static void IRAM_ATTR interrupt_handler(TCA8418Component *arg); + + protected: + /// Write the matrix / individual-input pin configuration. Returns false on I2C failure. + bool configure_pins_(); + /// Read every queued key event, dispatching them if asked, then clear the interrupt flags. + /// Returns false on I2C failure. + bool drain_events_(bool dispatch); + void dispatch_(uint8_t key, bool pressed); + /// The character mapped to a matrix key, or 0 when it has none. + uint8_t key_char_(uint8_t key) const; + + const uint8_t *keys_{nullptr}; // PROGMEM, indexed by key number - 1 + InternalGPIOPin *interrupt_pin_{nullptr}; + uint32_t last_poll_{0}; +#ifdef TCA8418_LISTENER_COUNT + StaticVector listeners_; +#endif + uint8_t rows_{0}; + uint8_t columns_{0}; + uint8_t keys_size_{0}; + bool gpi_events_{true}; +}; + +} // namespace esphome::tca8418 diff --git a/esphome/components/tca9548a/__init__.py b/esphome/components/tca9548a/__init__.py index 381b84cad6..368e34f325 100644 --- a/esphome/components/tca9548a/__init__.py +++ b/esphome/components/tca9548a/__init__.py @@ -1,10 +1,18 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_CHANNEL, CONF_CHANNELS, CONF_ID +from esphome.const import ( + CONF_CHANNEL, + CONF_CHANNELS, + CONF_FREQUENCY, + CONF_I2C_ID, + CONF_ID, +) +from esphome.core import CORE, ID from esphome.types import ConfigType CODEOWNERS = ["@andreashergert1984"] +DOMAIN = "tca9548a" DEPENDENCIES = ["i2c"] @@ -15,14 +23,51 @@ TCA9548AChannel = tca9548a_ns.class_("TCA9548AChannel", i2c.I2CBus) MULTI_CONF = True CONF_BUS_ID = "bus_id" +# Sizes the root bus's frequency table; only emitted when a frequency is set +_request_port_frequency_slot = cg.slot_counter("I2C_PORT_FREQUENCY_COUNT") + + +def _root_bus_id(bus_id: ID) -> ID: + """The hardware bus behind a port, through any multiplexers in between.""" + parents = { + str(chan[CONF_BUS_ID]): mux[CONF_I2C_ID] + for mux in CORE.config.get(DOMAIN, []) + for chan in mux.get(CONF_CHANNELS, []) + } + while (parent := parents.get(str(bus_id))) is not None: + bus_id = parent + return bus_id + + +def _declared_bus_type(bus_id: ID) -> cg.MockObjClass | None: + """The class the i2c component declared for a bus, or None for an external bus.""" + for bus in CORE.config.get(i2c.DOMAIN, []): + if bus[CONF_ID].id == bus_id.id: + return bus[CONF_ID].type + return None + + +async def _add_frequency(root_bus_id: ID, frequency: int) -> None: + """Count the frequency against the root bus; the built-in ESP32 bus also + creates its device handle in setup().""" + _request_port_frequency_slot(str(root_bus_id)) + if _declared_bus_type(root_bus_id) is i2c.IDFI2CBus: + root = await cg.get_variable(root_bus_id) + cg.add(root.add_frequency(frequency)) + + +_FREQUENCY = cv.All(cv.frequency, cv.Range(min=0, min_included=False)) + CONFIG_SCHEMA = ( cv.Schema( { cv.GenerateID(): cv.declare_id(TCA9548AComponent), + cv.Optional(CONF_FREQUENCY): _FREQUENCY, cv.Optional(CONF_CHANNELS, default=[]): cv.ensure_list( { cv.Required(CONF_BUS_ID): cv.declare_id(TCA9548AChannel), cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=7), + cv.Optional(CONF_FREQUENCY): _FREQUENCY, } ), } @@ -36,8 +81,15 @@ 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) + root_bus_id = _root_bus_id(config[CONF_I2C_ID]) + if (frequency := config.get(CONF_FREQUENCY)) is not None: + await _add_frequency(root_bus_id, int(frequency)) + cg.add(var.set_frequency(int(frequency))) for conf in config[CONF_CHANNELS]: chan = cg.new_Pvariable(conf[CONF_BUS_ID]) cg.add(chan.set_parent(var)) cg.add(chan.set_channel(conf[CONF_CHANNEL])) + if (frequency := conf.get(CONF_FREQUENCY)) is not None: + await _add_frequency(root_bus_id, int(frequency)) + cg.add(chan.set_frequency(int(frequency))) diff --git a/esphome/components/tca9548a/tca9548a.cpp b/esphome/components/tca9548a/tca9548a.cpp index 3fc91a3bbb..ac33ca8ded 100644 --- a/esphome/components/tca9548a/tca9548a.cpp +++ b/esphome/components/tca9548a/tca9548a.cpp @@ -1,26 +1,119 @@ #include "tca9548a.h" + +#include #include "esphome/core/log.h" namespace esphome::tca9548a { -static const char *const TAG = "tca9548a"; +ESPHOME_LOG_TAG(TAG, "tca9548a"); i2c::ErrorCode TCA9548AChannel::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, size_t read_count) { - auto err = this->parent_->switch_to_channel(channel_); +#ifdef I2C_PORT_FREQUENCY_COUNT + const uint32_t select_frequency = this->select_frequency_(); + if (select_frequency != 0) { + return this->write_readv_at_port_frequency_(select_frequency, address, write_buffer, write_count, read_buffer, + read_count); + } +#endif + auto err = this->parent_->switch_to_channel(this->channel_); if (err != i2c::ERROR_OK) return err; err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); this->parent_->disable_all_channels(); return err; } + +#ifdef I2C_PORT_FREQUENCY_COUNT +// The multiplexer's own frequency, else the slower of the bus and the port +uint32_t TCA9548AChannel::select_frequency_() const { + if (this->parent_->frequency_ != 0) + return this->parent_->frequency_; + if (this->frequency_ == 0) + return 0; + // A bus that does not report its frequency gets the port's, and fails the switch + const uint32_t bus_frequency = this->parent_->bus_->get_frequency(); + return bus_frequency != 0 ? std::min(this->frequency_, bus_frequency) : this->frequency_; +} + +// The port frequency, else the multiplexer's +uint32_t TCA9548AChannel::transfer_frequency_() const { + return this->frequency_ != 0 ? this->frequency_ : this->parent_->frequency_; +} + +i2c::ErrorCode TCA9548AChannel::write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, + const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count) { + const uint32_t original_frequency = this->parent_->bus_->get_frequency(); + // A failed switch leaves the bus as it was + auto err = this->switch_bus_(select_frequency); + if (err != i2c::ERROR_OK) + return err; + + err = this->parent_->switch_to_channel(this->channel_); + if (err == i2c::ERROR_OK) { + err = this->transfer_(select_frequency, address, write_buffer, write_count, read_buffer, read_count); + this->parent_->disable_all_channels(); + } + const i2c::ErrorCode restored = this->switch_bus_(original_frequency); + return err != i2c::ERROR_OK ? err : restored; +} + +// Transfer at the port frequency, back to the select frequency for the deselect +i2c::ErrorCode TCA9548AChannel::transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, + size_t write_count, uint8_t *read_buffer, size_t read_count) { + const uint32_t transfer_frequency = this->transfer_frequency_(); + if (transfer_frequency == select_frequency) { + return this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); + } + auto err = this->switch_bus_(transfer_frequency); + if (err == i2c::ERROR_OK) { + err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count); + } + const i2c::ErrorCode restored = this->switch_bus_(select_frequency); + return err != i2c::ERROR_OK ? err : restored; +} + +// Forward to the shared upstream bus +i2c::ErrorCode TCA9548AChannel::switch_frequency(uint32_t frequency) { + return this->parent_->bus_->switch_frequency(frequency); +} + +i2c::ErrorCode TCA9548AChannel::switch_bus_(uint32_t frequency) { + const i2c::ErrorCode err = this->switch_frequency(frequency); + if (err != i2c::ERROR_OK) { + this->parent_->status_set_error(LOG_STR("Failed to switch the bus frequency")); + } + return err; +} + +uint32_t TCA9548AChannel::get_frequency() const { return this->parent_->bus_->get_frequency(); } +#endif + void TCA9548AComponent::setup() { uint8_t status = 0; - if (this->read(&status, 1) != i2c::ERROR_OK) { +#ifdef I2C_PORT_FREQUENCY_COUNT + const uint32_t original_frequency = this->bus_->get_frequency(); + if (this->frequency_ != 0 && this->bus_->switch_frequency(this->frequency_) != i2c::ERROR_OK) { + this->mark_failed(LOG_STR("Failed to switch the bus frequency")); + return; + } +#endif + const i2c::ErrorCode err = this->read(&status, 1); +#ifdef I2C_PORT_FREQUENCY_COUNT + const bool restored = this->frequency_ == 0 || this->bus_->switch_frequency(original_frequency) == i2c::ERROR_OK; +#endif + if (err != i2c::ERROR_OK) { ESP_LOGE(TAG, "TCA9548A failed"); this->mark_failed(); return; } +#ifdef I2C_PORT_FREQUENCY_COUNT + if (!restored) { + this->mark_failed(LOG_STR("Failed to switch the bus frequency")); + return; + } +#endif ESP_LOGD(TAG, "Channels currently open: %d", status); } void TCA9548AComponent::dump_config() { @@ -32,14 +125,13 @@ i2c::ErrorCode TCA9548AComponent::switch_to_channel(uint8_t channel) { if (this->is_failed()) return i2c::ERROR_NOT_INITIALIZED; - uint8_t channel_val = 1 << channel; + const uint8_t channel_val = 1 << channel; return this->write(&channel_val, 1); } void TCA9548AComponent::disable_all_channels() { if (this->write(&TCA9548A_DISABLE_CHANNELS_COMMAND, 1) != i2c::ERROR_OK) { - ESP_LOGE(TAG, "Failed to disable all channels."); - this->status_set_error(); // couldn't disable channels, set error status + this->status_set_error(LOG_STR("Failed to disable all channels.")); } } diff --git a/esphome/components/tca9548a/tca9548a.h b/esphome/components/tca9548a/tca9548a.h index a98c226d32..5b0d8a0a76 100644 --- a/esphome/components/tca9548a/tca9548a.h +++ b/esphome/components/tca9548a/tca9548a.h @@ -10,13 +10,30 @@ static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00; class TCA9548AComponent; class TCA9548AChannel final : public i2c::I2CBus { public: - void set_channel(uint8_t channel) { channel_ = channel; } - void set_parent(TCA9548AComponent *parent) { parent_ = parent; } + void set_channel(uint8_t channel) { this->channel_ = channel; } + void set_parent(TCA9548AComponent *parent) { this->parent_ = parent; } i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer, size_t read_count) override; +#ifdef I2C_PORT_FREQUENCY_COUNT + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } + i2c::ErrorCode switch_frequency(uint32_t frequency) override; + uint32_t get_frequency() const override; +#endif + protected: +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t select_frequency_() const; + uint32_t transfer_frequency_() const; + i2c::ErrorCode write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, + size_t write_count, uint8_t *read_buffer, size_t read_count); + i2c::ErrorCode transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, size_t write_count, + uint8_t *read_buffer, size_t read_count); + /// Switch the upstream bus, flagging the multiplexer on failure + i2c::ErrorCode switch_bus_(uint32_t frequency); + uint32_t frequency_{0}; +#endif uint8_t channel_; TCA9548AComponent *parent_; }; @@ -30,8 +47,15 @@ class TCA9548AComponent final : public Component, public i2c::I2CDevice { i2c::ErrorCode switch_to_channel(uint8_t channel); void disable_all_channels(); +#ifdef I2C_PORT_FREQUENCY_COUNT + /// Addressing frequency, and the default for the ports + void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } +#endif protected: friend class TCA9548AChannel; +#ifdef I2C_PORT_FREQUENCY_COUNT + uint32_t frequency_{0}; +#endif }; } // namespace esphome::tca9548a diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index 3033cc65e0..a039872e16 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -15,6 +15,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@mobrembski"] +DOMAIN = "tca9555" AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] @@ -62,7 +63,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/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index b210c082dd..7a59c403f3 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -12,7 +12,7 @@ static const uint8_t TCA9555_CONFIGURATION_PORT_1 = 0x07; namespace esphome::tca9555 { -static const char *const TAG = "tca9555"; +ESPHOME_LOG_TAG(TAG, "tca9555"); void TCA9555Component::setup() { if (!this->read_gpio_modes_()) { diff --git a/esphome/components/tcl112/__init__.py b/esphome/components/tcl112/__init__.py index e69de29bb2..03f3908817 100644 --- a/esphome/components/tcl112/__init__.py +++ b/esphome/components/tcl112/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tcl112" diff --git a/esphome/components/tcl112/tcl112.cpp b/esphome/components/tcl112/tcl112.cpp index cd819e5b16..15111338b2 100644 --- a/esphome/components/tcl112/tcl112.cpp +++ b/esphome/components/tcl112/tcl112.cpp @@ -3,7 +3,7 @@ namespace esphome::tcl112 { -static const char *const TAG = "tcl112.climate"; +ESPHOME_LOG_TAG(TAG, "tcl112.climate"); const uint16_t TCL112_STATE_LENGTH = 14; const uint16_t TCL112_BITS = TCL112_STATE_LENGTH * 8; @@ -168,11 +168,9 @@ bool Tcl112Climate::on_receive(remote_base::RemoteReceiveData data) { return false; } - uint8_t checksum = 0; // Calculate & set the checksum for the current internal state of the remote. // Stored the checksum value in the last byte. - for (uint8_t checksum_byte = 0; checksum_byte < TCL112_STATE_LENGTH - 1; checksum_byte++) - checksum += remote_state[checksum_byte]; + const uint8_t checksum = sum_bytes(remote_state, TCL112_STATE_LENGTH - 1); if (checksum != remote_state[TCL112_STATE_LENGTH - 1]) { ESP_LOGVV(TAG, "Checksum fail"); return false; diff --git a/esphome/components/tcp_uart/__init__.py b/esphome/components/tcp_uart/__init__.py new file mode 100644 index 0000000000..df9c1720ee --- /dev/null +++ b/esphome/components/tcp_uart/__init__.py @@ -0,0 +1,106 @@ +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"] +DOMAIN = "tcp_uart" +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.All(cv.declare_id(TcpUart), uart.mark_unclocked), + 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..3dead1fb65 --- /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 { + +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/tcs34725/__init__.py index e69de29bb2..cf99348a86 100644 --- a/esphome/components/tcs34725/__init__.py +++ b/esphome/components/tcs34725/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tcs34725" diff --git a/esphome/components/tcs34725/sensor.py b/esphome/components/tcs34725/sensor.py index da6edfd62a..e19d76a64a 100644 --- a/esphome/components/tcs34725/sensor.py +++ b/esphome/components/tcs34725/sensor.py @@ -1,5 +1,11 @@ import esphome.codegen as cg from esphome.components import i2c, sensor +from esphome.components.const import ( + CONF_BLUE_CHANNEL, + CONF_CLEAR_CHANNEL, + CONF_GREEN_CHANNEL, + CONF_RED_CHANNEL, +) import esphome.config_validation as cv from esphome.const import ( CONF_COLOR_TEMPERATURE, @@ -20,11 +26,6 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] -CONF_RED_CHANNEL = "red_channel" -CONF_GREEN_CHANNEL = "green_channel" -CONF_BLUE_CHANNEL = "blue_channel" -CONF_CLEAR_CHANNEL = "clear_channel" - tcs34725_ns = cg.esphome_ns.namespace("tcs34725") TCS34725Component = tcs34725_ns.class_( "TCS34725Component", cg.PollingComponent, i2c.I2CDevice @@ -113,21 +114,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/tcs34725/tcs34725.cpp b/esphome/components/tcs34725/tcs34725.cpp index b585392790..8e79ac1c16 100644 --- a/esphome/components/tcs34725/tcs34725.cpp +++ b/esphome/components/tcs34725/tcs34725.cpp @@ -6,7 +6,7 @@ namespace esphome::tcs34725 { -static const char *const TAG = "tcs34725"; +ESPHOME_LOG_TAG(TAG, "tcs34725"); static const uint8_t TCS34725_ADDRESS = 0x29; static const uint8_t TCS34725_COMMAND_BIT = 0x80; diff --git a/esphome/components/tee501/__init__.py b/esphome/components/tee501/__init__.py index e69de29bb2..d3790674e2 100644 --- a/esphome/components/tee501/__init__.py +++ b/esphome/components/tee501/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tee501" diff --git a/esphome/components/tee501/tee501.cpp b/esphome/components/tee501/tee501.cpp index c198ff1081..74906ba5c4 100644 --- a/esphome/components/tee501/tee501.cpp +++ b/esphome/components/tee501/tee501.cpp @@ -4,7 +4,7 @@ namespace esphome::tee501 { -static const char *const TAG = "tee501"; +ESPHOME_LOG_TAG(TAG, "tee501"); static constexpr size_t TEE501_SERIAL_NUMBER_SIZE = 7; diff --git a/esphome/components/teleinfo/__init__.py b/esphome/components/teleinfo/__init__.py index 67aad11d0f..5a6a01f669 100644 --- a/esphome/components/teleinfo/__init__.py +++ b/esphome/components/teleinfo/__init__.py @@ -4,7 +4,8 @@ import esphome.config_validation as cv from esphome.const import CONF_ID from esphome.types import ConfigType -CODEOWNERS = ["@0hax"] +CODEOWNERS = ["@0hax", "@FredM67"] +DOMAIN = "teleinfo" MULTI_CONF = True teleinfo_ns = cg.esphome_ns.namespace("teleinfo") diff --git a/esphome/components/teleinfo/sensor/__init__.py b/esphome/components/teleinfo/sensor/__init__.py index b51d4cb795..6a23836c8a 100644 --- a/esphome/components/teleinfo/sensor/__init__.py +++ b/esphome/components/teleinfo/sensor/__init__.py @@ -1,10 +1,32 @@ import esphome.codegen as cg from esphome.components import sensor +import esphome.config_validation as cv from esphome.const import ( + CONF_DEVICE_CLASS, + CONF_ICON, CONF_ID, + CONF_STATE_CLASS, + CONF_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_REACTIVE_ENERGY, + DEVICE_CLASS_VOLTAGE, ICON_FLASH, + STATE_CLASS_MEASUREMENT, STATE_CLASS_TOTAL_INCREASING, + UNIT_AMPERE, + UNIT_KILOVOLT_AMPS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOWATT, + UNIT_KILOWATT_HOURS, + UNIT_MILLIAMP, + UNIT_MILLIVOLT, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_WATT, UNIT_WATT_HOURS, ) from esphome.types import ConfigType @@ -13,19 +35,125 @@ from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, telein TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component) -CONFIG_SCHEMA = sensor.sensor_schema( - TeleInfoSensor, - unit_of_measurement=UNIT_WATT_HOURS, - icon=ICON_FLASH, - accuracy_decimals=0, - device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_TOTAL_INCREASING, -).extend(TELEINFO_LISTENER_SCHEMA) + +def _preset(unit: str, device_class: str, state_class: str) -> dict[str, str]: + return { + CONF_UNIT_OF_MEASUREMENT: unit, + CONF_DEVICE_CLASS: device_class, + CONF_STATE_CLASS: state_class, + } + + +_ENERGY = _preset(UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY, STATE_CLASS_TOTAL_INCREASING) +_REACTIVE_ENERGY = _preset( + UNIT_VOLT_AMPS_REACTIVE_HOURS, + DEVICE_CLASS_REACTIVE_ENERGY, + STATE_CLASS_TOTAL_INCREASING, +) +_CURRENT = _preset(UNIT_AMPERE, DEVICE_CLASS_CURRENT, STATE_CLASS_MEASUREMENT) +_VOLTAGE = _preset(UNIT_VOLT, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT) +_APPARENT_POWER_VA = _preset( + UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_APPARENT_POWER_KVA = _preset( + UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT +) +_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT) + +# Legacy defaults, kept for tags without a known preset +_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH} + +# Units that are valid for each device class, used to decide whether a preset's +# device class can be kept when the user overrides the unit (and vice versa) +_COMPATIBLE_UNITS = { + DEVICE_CLASS_ENERGY: {UNIT_WATT_HOURS, UNIT_KILOWATT_HOURS}, + DEVICE_CLASS_REACTIVE_ENERGY: { + UNIT_VOLT_AMPS_REACTIVE_HOURS, + UNIT_KILOVOLT_AMPS_REACTIVE_HOURS, + }, + DEVICE_CLASS_CURRENT: {UNIT_AMPERE, UNIT_MILLIAMP}, + DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT}, + DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS}, + DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT}, +} + +# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant. +TIC_TAG_CONFIGS = { + # Standard mode + "EA": _ENERGY, + "ER": _REACTIVE_ENERGY, + "IRMS": _CURRENT, + "URMS": _VOLTAGE, + "UMOY": _VOLTAGE, + "SINST": _APPARENT_POWER_VA, + "SMAX": _APPARENT_POWER_VA, + "CC": _POWER, + "PREF": _APPARENT_POWER_KVA, + "PCOUP": _APPARENT_POWER_KVA, + # Historical mode + "BASE": _ENERGY, + "HCH": _ENERGY, + "EJP": _ENERGY, + "BBRH": _ENERGY, + "IINST": _CURRENT, + "IMAX": _CURRENT, + "ISOUSC": _CURRENT, + "ADPS": _CURRENT, + "ADIR": _CURRENT, + "PAPP": _APPARENT_POWER_VA, + "PMAX": _POWER, +} + + +def apply_tag_config(config: ConfigType) -> ConfigType: + """Apply preset configurations based on the tag name. + + Only keys not set by the user are filled in. For known tags, a preset unit or + device class is dropped if it is incompatible with the one set by the user. + Unknown tags get the legacy defaults, merged key by key as before. + """ + if CONF_TAG_NAME not in config: + return config + + # Runs before cv.string, so the raw value may not be a string yet + tag_name = str(config[CONF_TAG_NAME]) + preset = next( + (p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)), + None, + ) + + config = dict(config) + if preset is None: + for key, value in _LEGACY_DEFAULT.items(): + config.setdefault(key, value) + return config + + unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT]) + device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS]) + compatible = unit in _COMPATIBLE_UNITS.get(device_class, ()) + + for key, value in preset.items(): + if key in config: + continue + if key in (CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE_CLASS) and not compatible: + continue + config[key] = value + return config + + +CONFIG_SCHEMA = cv.All( + apply_tag_config, + sensor.sensor_schema( + TeleInfoSensor, + accuracy_decimals=0, + ).extend(TELEINFO_LISTENER_SCHEMA), +) async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID], config[CONF_TAG_NAME]) await cg.register_component(var, config) await sensor.register_sensor(var, config) + teleinfo = await cg.get_variable(config[CONF_TELEINFO_ID]) cg.add(teleinfo.register_teleinfo_listener(var)) diff --git a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp index 3878a3967b..443d4be8a1 100644 --- a/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp +++ b/esphome/components/teleinfo/sensor/teleinfo_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::teleinfo { -static const char *const TAG = "teleinfo_sensor"; +ESPHOME_LOG_TAG(TAG, "teleinfo_sensor"); TeleInfoSensor::TeleInfoSensor(const char *tag) { this->tag = std::string(tag); } void TeleInfoSensor::publish_val(const std::string &val) { auto newval = parse_number(val).value_or(0.0f); diff --git a/esphome/components/teleinfo/teleinfo.cpp b/esphome/components/teleinfo/teleinfo.cpp index 17d3d6c099..1d3813d59e 100644 --- a/esphome/components/teleinfo/teleinfo.cpp +++ b/esphome/components/teleinfo/teleinfo.cpp @@ -3,33 +3,33 @@ namespace esphome::teleinfo { -static const char *const TAG = "teleinfo"; +ESPHOME_LOG_TAG(TAG, "teleinfo"); /* Helpers */ -static int get_field(char *dest, char *buf_start, char *buf_end, int sep, int max_len) { - char *field_end; +static int get_field(char *dest, uint8_t *buf_start, uint8_t *buf_end, int sep, int max_len) { + uint8_t *field_end; int len; - field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); + field_end = static_cast(memchr(buf_start, sep, buf_end - buf_start)); if (!field_end) return 0; len = field_end - buf_start; if (len >= max_len) return len; - strncpy(dest, buf_start, len); + memcpy(dest, buf_start, len); dest[len] = '\0'; return len; } /* TeleInfo methods */ -bool TeleInfo::check_crc_(const char *grp, const char *grp_end) { +bool TeleInfo::check_crc_(const uint8_t *grp, const uint8_t *grp_end) { int grp_len = grp_end - grp; + if (grp_len < this->checksum_area_end_) { + ESP_LOGE(TAG, "Empty or too short group"); + return false; + } uint8_t raw_crc = grp[grp_len - 1]; - uint8_t crc_tmp = 0; - int i; - - for (i = 0; i < grp_len - checksum_area_end_; i++) - crc_tmp += grp[i]; + uint8_t crc_tmp = sum_bytes(grp, grp_len - this->checksum_area_end_); crc_tmp &= 0x3F; crc_tmp += 0x20; @@ -46,7 +46,8 @@ bool TeleInfo::read_chars_until_(bool drop, uint8_t c) { while (available() > 0 && j < 128) { j++; - received = read(); + if (!read_byte(&received)) + break; if (received == c) return true; if (drop) @@ -87,9 +88,9 @@ void TeleInfo::loop() { state_ = END_FRAME_RECEIVED; break; case END_FRAME_RECEIVED: - char *buf_finger; - char *grp_end; - char *buf_end; + uint8_t *buf_finger; + uint8_t *grp_end; + uint8_t *buf_end; int field_len; buf_finger = buf_; @@ -116,8 +117,7 @@ void TeleInfo::loop() { * 0xa | Tag | 0x9 | Timestamp | 0x9 | 0x9 | CRC | 0xd * */ - while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_index_ - 1))) && - ((buf_finger - buf_) < buf_index_)) { // NOLINT(clang-diagnostic-sign-compare) + while ((buf_finger = static_cast(memchr(buf_finger, (int) 0xa, buf_end - buf_finger)))) { /* * Make sure timesamp is nullified between each tag as some tags don't * have a timestamp @@ -127,7 +127,7 @@ void TeleInfo::loop() { buf_finger += 1; /* Group len */ - grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); + grp_end = static_cast(memchr(buf_finger, 0xd, buf_end - buf_finger)); if (!grp_end) { ESP_LOGE(TAG, "No group found"); break; @@ -154,7 +154,7 @@ void TeleInfo::loop() { if (std::count(buf_finger, grp_end, separator_) == 2 && strcmp(tag_, "DATE") != 0 && separator_ != 0x20) { field_len = get_field(timestamp_, buf_finger, grp_end, separator_, MAX_TIMESTAMP_SIZE); if (!field_len || field_len >= MAX_TIMESTAMP_SIZE) { - ESP_LOGE(TAG, "Invalid timestamp for tag %s", timestamp_); + ESP_LOGE(TAG, "Invalid timestamp for tag %s", tag_); continue; } @@ -168,8 +168,8 @@ void TeleInfo::loop() { continue; } - /* Advance buf_finger to end of group */ - buf_finger += field_len + 1 + 1 + 1; + /* Resume after the group's 0xd; grp_end < buf_end, so this stays in bounds. */ + buf_finger = grp_end + 1; publish_value_(std::string(tag_), std::string(val_)); } diff --git a/esphome/components/teleinfo/teleinfo.h b/esphome/components/teleinfo/teleinfo.h index b1bf586e9c..39f0287411 100644 --- a/esphome/components/teleinfo/teleinfo.h +++ b/esphome/components/teleinfo/teleinfo.h @@ -33,7 +33,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { protected: int checksum_area_end_; int separator_; - char buf_[MAX_BUF_SIZE]; + uint8_t buf_[MAX_BUF_SIZE]; uint32_t buf_index_{0}; char tag_[MAX_TAG_SIZE]; char val_[MAX_VAL_SIZE]; @@ -45,7 +45,7 @@ class TeleInfo final : public PollingComponent, public uart::UARTDevice { END_FRAME_RECEIVED, } state_{STATE_OFF}; bool read_chars_until_(bool drop, uint8_t c); - bool check_crc_(const char *grp, const char *grp_end); + bool check_crc_(const uint8_t *grp, const uint8_t *grp_end); void publish_value_(const std::string &tag, const std::string &val); }; } // namespace esphome::teleinfo diff --git a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp index 7c638d8545..a2646e5bb7 100644 --- a/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp +++ b/esphome/components/teleinfo/text_sensor/teleinfo_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::teleinfo { -static const char *const TAG = "teleinfo_text_sensor"; +ESPHOME_LOG_TAG(TAG, "teleinfo_text_sensor"); TeleInfoTextSensor::TeleInfoTextSensor(const char *tag) { this->tag = std::string(tag); } void TeleInfoTextSensor::publish_val(const std::string &val) { publish_state(val); } void TeleInfoTextSensor::dump_config() { LOG_TEXT_SENSOR(" ", "Teleinfo Text Sensor", this); } diff --git a/esphome/components/tem3200/__init__.py b/esphome/components/tem3200/__init__.py index e69de29bb2..728db67d54 100644 --- a/esphome/components/tem3200/__init__.py +++ b/esphome/components/tem3200/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tem3200" 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/tem3200/tem3200.cpp b/esphome/components/tem3200/tem3200.cpp index 72cf31e0a6..e964814bbb 100644 --- a/esphome/components/tem3200/tem3200.cpp +++ b/esphome/components/tem3200/tem3200.cpp @@ -5,7 +5,7 @@ namespace esphome::tem3200 { -static const char *const TAG = "tem3200"; +ESPHOME_LOG_TAG(TAG, "tem3200"); enum ErrorCode { NONE = 0, diff --git a/esphome/components/template/__init__.py b/esphome/components/template/__init__.py index 6253af9090..542e78809c 100644 --- a/esphome/components/template/__init__.py +++ b/esphome/components/template/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "template" + template_ns = cg.esphome_ns.namespace("template_") diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index 59624a5f53..a708c83d09 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -117,8 +117,13 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): var = await alarm_control_panel.new_alarm_control_panel(config) await cg.register_component(var, config) - if CONF_CODES in config: - cg.add(var.set_codes(config[CONF_CODES])) + # Shared flash table ended by nullptr, so the panel stores only a pointer. + # requires_code_to_arm needs codes (validate_config) and defaults to false. + if codes := config.get(CONF_CODES): + table = cg.shared_progmem_array( + "alarm_codes", cg.const_char_ptr, [*codes, cg.nullptr] + ) + cg.add(var.set_codes(table)) if CONF_REQUIRES_CODE_TO_ARM in config: cg.add(var.set_requires_code_to_arm(config[CONF_REQUIRES_CODE_TO_ARM])) diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index a224ab8459..12ff0b5a4b 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -11,7 +11,7 @@ namespace esphome::template_ { using namespace esphome::alarm_control_panel; -static const char *const TAG = "template.alarm_control_panel"; +ESPHOME_LOG_TAG(TAG, "template.alarm_control_panel"); TemplateAlarmControlPanel::TemplateAlarmControlPanel(){}; @@ -36,6 +36,11 @@ static const LogString *sensor_type_to_string(AlarmSensorType type) { #endif void TemplateAlarmControlPanel::dump_config() { + size_t num_codes = 0; + if (this->codes_ != nullptr) { + while (this->codes_[num_codes] != nullptr) + num_codes++; + } ESP_LOGCONFIG(TAG, "TemplateAlarmControlPanel:\n" " Current State: %s\n" @@ -47,8 +52,8 @@ void TemplateAlarmControlPanel::dump_config() { " Pending Time: %" PRIu32 "s\n" " Trigger Time: %" PRIu32 "s\n" " Supported Features: %" PRIu32, - LOG_STR_ARG(alarm_control_panel_state_to_string(this->current_state_)), this->codes_.size(), - YESNO(!this->codes_.empty() && this->requires_code_to_arm_), (this->arming_away_time_ / 1000), + LOG_STR_ARG(alarm_control_panel_state_to_string(this->current_state_)), num_codes, + YESNO(this->codes_ != nullptr && this->requires_code_to_arm_), (this->arming_away_time_ / 1000), (this->arming_home_time_ / 1000), (this->arming_night_time_ / 1000), (this->pending_time_ / 1000), (this->trigger_time_ / 1000), this->get_supported_features()); #ifdef USE_BINARY_SENSOR @@ -194,13 +199,12 @@ void TemplateAlarmControlPanel::loop() { } bool TemplateAlarmControlPanel::is_code_valid_(optional code) { - if (!this->codes_.empty()) { + if (this->codes_ != nullptr) { if (code.has_value()) { ESP_LOGVV(TAG, "Checking code: %s", code.value().c_str()); - // Use strcmp for const char* comparison const char *code_cstr = code.value().c_str(); - for (const char *stored_code : this->codes_) { - if (strcmp(stored_code, code_cstr) == 0) + for (const char *const *stored = this->codes_; *stored != nullptr; stored++) { + if (strcmp(*stored, code_cstr) == 0) return true; } return false; 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 5888ce5e29..c977da10a9 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 @@ -61,7 +61,7 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl void setup() override; void loop() override; uint32_t get_supported_features() const override; - bool get_requires_code() const override { return !this->codes_.empty(); } + bool get_requires_code() const override { return this->codes_ != nullptr; } 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; } @@ -83,14 +83,11 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl AlarmSensorType type = ALARM_SENSOR_TYPE_DELAYED); #endif - /** Set the codes (from initializer list). + /** Set the codes. * - * @param codes The list of valid codes + * @param codes nullptr-terminated table of valid codes; must outlive the panel */ - void set_codes(std::initializer_list codes) { this->codes_ = codes; } - - // Deleted overload to catch incorrect std::string usage at compile time - void set_codes(std::initializer_list codes) = delete; + void set_codes(const char *const *codes) { this->codes_ = codes; } /** set requires a code to arm * @@ -150,8 +147,8 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl uint32_t pending_time_; // the time in trigger uint32_t trigger_time_; - // a list of codes (const char* pointers to string literals in flash) - FixedVector codes_; + // nullptr-terminated table of codes, shared in flash + const char *const *codes_{nullptr}; // requires a code to arm bool requires_code_to_arm_ = false; bool supports_arm_home_ = false; diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.cpp b/esphome/components/template/binary_sensor/template_binary_sensor.cpp index b63121d2db..8ce51aa4ca 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.cpp +++ b/esphome/components/template/binary_sensor/template_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "template.binary_sensor"); void TemplateBinarySensor::setup() { if (!this->f_.has_value()) { diff --git a/esphome/components/template/climate/__init__.py b/esphome/components/template/climate/__init__.py index 11ffaf282c..5f7627c5c3 100644 --- a/esphome/components/template/climate/__init__.py +++ b/esphome/components/template/climate/__init__.py @@ -374,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( diff --git a/esphome/components/template/climate/template_climate.cpp b/esphome/components/template/climate/template_climate.cpp index a7a4d2ccab..a731f037e3 100644 --- a/esphome/components/template/climate/template_climate.cpp +++ b/esphome/components/template/climate/template_climate.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.climate"; +ESPHOME_LOG_TAG(TAG, "template.climate"); void TemplateClimate::setup() { if (this->restore_mode_ == TemplateClimateRestoreMode::TEMPLATE_CLIMATE_RESTORE_MODE_RESTORE) { diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 1bf057da5b..88a0af10b5 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::template_ { using namespace esphome::cover; -static const char *const TAG = "template.cover"; +ESPHOME_LOG_TAG(TAG, "template.cover"); TemplateCover::TemplateCover() = default; void TemplateCover::setup() { diff --git a/esphome/components/template/datetime/template_date.cpp b/esphome/components/template/datetime/template_date.cpp index c0f5d96c3d..4ae7429e96 100644 --- a/esphome/components/template/datetime/template_date.cpp +++ b/esphome/components/template/datetime/template_date.cpp @@ -6,7 +6,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.date"; +ESPHOME_LOG_TAG(TAG, "template.date"); void TemplateDate::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/datetime/template_datetime.cpp b/esphome/components/template/datetime/template_datetime.cpp index 5b8b308c00..0776a6968a 100644 --- a/esphome/components/template/datetime/template_datetime.cpp +++ b/esphome/components/template/datetime/template_datetime.cpp @@ -6,7 +6,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.datetime"; +ESPHOME_LOG_TAG(TAG, "template.datetime"); void TemplateDateTime::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp index b5efa62ae7..fd676e66a6 100644 --- a/esphome/components/template/datetime/template_time.cpp +++ b/esphome/components/template/datetime/template_time.cpp @@ -6,7 +6,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.time"; +ESPHOME_LOG_TAG(TAG, "template.time"); void TemplateTime::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index 431be84654..93b02f1245 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.fan"; +ESPHOME_LOG_TAG(TAG, "template.fan"); void TemplateFan::setup() { // Construct traits before restore so preset modes can be looked up by index diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 4a293aab85..54e4e49ce9 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -5,7 +5,7 @@ namespace esphome::template_ { using namespace esphome::lock; -static const char *const TAG = "template.lock"; +ESPHOME_LOG_TAG(TAG, "template.lock"); TemplateLock::TemplateLock() = default; diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp index 64c2deb281..93eb42708e 100644 --- a/esphome/components/template/number/template_number.cpp +++ b/esphome/components/template/number/template_number.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.number"; +ESPHOME_LOG_TAG(TAG, "template.number"); void TemplateNumber::setup() { if (this->f_.has_value()) 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.cpp b/esphome/components/template/select/template_select.cpp index e68729c2d4..8c46b3a58c 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.select"; +ESPHOME_LOG_TAG(TAG, "template.select"); void dump_config_helper(BaseTemplateSelect *sel_comp, bool optimistic, bool has_lambda, const size_t initial_option_index, bool restore_value) { diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 1cc28a36d3..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, @@ -59,8 +60,8 @@ class TemplateSelect : public BaseTemplateSelect { if constexpr (RESTORE_VALUE) this->pref_.save(&index); } - [[no_unique_address]] std::conditional_t, Empty> f_{}; - [[no_unique_address]] std::conditional_t pref_{}; + [[no_unique_address]] std::conditional_t, Empty<0>> f_{}; + [[no_unique_address]] std::conditional_t> pref_{}; }; /// Template select with set_action trigger - only instantiated when set_action is configured diff --git a/esphome/components/template/sensor/template_sensor.cpp b/esphome/components/template/sensor/template_sensor.cpp index 313a163e38..cc12d7bbec 100644 --- a/esphome/components/template/sensor/template_sensor.cpp +++ b/esphome/components/template/sensor/template_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.sensor"; +ESPHOME_LOG_TAG(TAG, "template.sensor"); void TemplateSensor::update() { if (!this->f_.has_value()) diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 729db37053..456e93d13d 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.switch"; +ESPHOME_LOG_TAG(TAG, "template.switch"); TemplateSwitch::TemplateSwitch() = default; diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index af134e6ed4..d892afade4 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.text"; +ESPHOME_LOG_TAG(TAG, "template.text"); void TemplateText::setup() { if (this->f_.has_value()) diff --git a/esphome/components/template/text_sensor/template_text_sensor.cpp b/esphome/components/template/text_sensor/template_text_sensor.cpp index 89a15b6081..38de4ea8de 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.cpp +++ b/esphome/components/template/text_sensor/template_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.text_sensor"; +ESPHOME_LOG_TAG(TAG, "template.text_sensor"); void TemplateTextSensor::update() { if (!this->f_.has_value()) 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..4e8a272285 --- /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_ { + +ESPHOME_LOG_TAG(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/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index 5090687639..77e65f0457 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -5,7 +5,7 @@ namespace esphome::template_ { using namespace esphome::valve; -static const char *const TAG = "template.valve"; +ESPHOME_LOG_TAG(TAG, "template.valve"); TemplateValve::TemplateValve() = default; diff --git a/esphome/components/template/water_heater/template_water_heater.cpp b/esphome/components/template/water_heater/template_water_heater.cpp index 9d6a3523d2..0a71225de3 100644 --- a/esphome/components/template/water_heater/template_water_heater.cpp +++ b/esphome/components/template/water_heater/template_water_heater.cpp @@ -5,7 +5,7 @@ namespace esphome::template_ { -static const char *const TAG = "template.water_heater"; +ESPHOME_LOG_TAG(TAG, "template.water_heater"); TemplateWaterHeater::TemplateWaterHeater() = default; @@ -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 2f82f19ff5..243185b674 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -9,7 +9,6 @@ from esphome.const import ( CONF_MODE, CONF_MQTT_ID, CONF_ON_VALUE, - CONF_TRIGGER_ID, CONF_VALUE, CONF_WEB_SERVER, ) @@ -23,17 +22,13 @@ from esphome.cpp_generator import MockObj, MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@mauritskorse"] +DOMAIN = "text" IS_PLATFORM_COMPONENT = True text_ns = cg.esphome_ns.namespace("text") Text = text_ns.class_("Text", cg.EntityBase) TextPtr = Text.operator("ptr") -# Triggers -TextStateTrigger = text_ns.class_( - "TextStateTrigger", automation.Trigger.template(cg.std_string) -) - # Conditions TextMode = text_ns.enum("TextMode") @@ -49,11 +44,7 @@ _TEXT_SCHEMA = ( { cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextComponent), cv.GenerateID(): cv.declare_id(Text), - cv.Optional(CONF_ON_VALUE): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TextStateTrigger), - } - ), + cv.Optional(CONF_ON_VALUE): automation.validate_automation(), cv.Required(CONF_MODE): cv.enum(TEXT_MODES, upper=True), } ) @@ -103,8 +94,9 @@ async def setup_text_core_( cg.add(var.traits.set_mode(config[CONF_MODE])) for conf in config.get(CONF_ON_VALUE, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + await automation.build_callback_automation( + var, "add_on_state_callback", [(cg.std_string, "x")], conf + ) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) diff --git a/esphome/components/text/automation.h b/esphome/components/text/automation.h deleted file mode 100644 index bd6fb3cd23..0000000000 --- a/esphome/components/text/automation.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "text.h" - -namespace esphome::text { - -class TextStateTrigger final : public Trigger { - public: - explicit TextStateTrigger(Text *parent) { - parent->add_on_state_callback([this](const std::string &value) { this->trigger(value); }); - } -}; - -} // namespace esphome::text diff --git a/esphome/components/text/text.cpp b/esphome/components/text/text.cpp index a1df6286c7..58ab300b90 100644 --- a/esphome/components/text/text.cpp +++ b/esphome/components/text/text.cpp @@ -6,7 +6,7 @@ namespace esphome::text { -static const char *const TAG = "text"; +ESPHOME_LOG_TAG(TAG, "text"); void Text::publish_state(const char *state, size_t len) { this->set_has_state(true); diff --git a/esphome/components/text/text_call.cpp b/esphome/components/text/text_call.cpp index b7692658af..93f0610d7d 100644 --- a/esphome/components/text/text_call.cpp +++ b/esphome/components/text/text_call.cpp @@ -4,7 +4,7 @@ namespace esphome::text { -static const char *const TAG = "text"; +ESPHOME_LOG_TAG(TAG, "text"); TextCall &TextCall::set_value(const std::string &value) { this->value_ = value; diff --git a/esphome/components/text/text_sensor/text_text_sensor.cpp b/esphome/components/text/text_sensor/text_text_sensor.cpp index 50504c605e..6574141e9f 100644 --- a/esphome/components/text/text_sensor/text_text_sensor.cpp +++ b/esphome/components/text/text_sensor/text_text_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::text { -static const char *const TAG = "text.text_sensor"; +ESPHOME_LOG_TAG(TAG, "text.text_sensor"); void TextTextSensor::setup() { this->source_->add_on_state_callback([this](const std::string &value) { this->publish_state(value); }); diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 34c46d5451..2ff0421f39 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 = [ @@ -37,6 +39,7 @@ DEVICE_CLASSES = [ ] +DOMAIN = "text_sensor" IS_PLATFORM_COMPONENT = True text_sensor_ns = cg.esphome_ns.namespace("text_sensor") @@ -56,6 +59,7 @@ AppendFilter = text_sensor_ns.class_("AppendFilter", Filter) PrependFilter = text_sensor_ns.class_("PrependFilter", Filter) SubstituteFilter = text_sensor_ns.class_("SubstituteFilter", Filter) MapFilter = text_sensor_ns.class_("MapFilter", Filter) +Substitution = text_sensor_ns.struct("Substitution") @FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda) @@ -99,34 +103,29 @@ def validate_mapping(value): )(value) -@FILTER_REGISTRY.register( - "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) -) -async def substitute_filter_to_code(config, filter_id): - substitutions = [ +def _substitution_table(config: list[ConfigType]) -> MockObj: + """Shared PROGMEM Substitution table, ended by an empty pair so filters store no count.""" + pairs = [ cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), + Substitution, ("from", conf[CONF_FROM]), ("to", conf[CONF_TO]) ) for conf in config ] - return cg.new_Pvariable( - filter_id, cg.TemplateArguments(len(substitutions)), substitutions + end = cg.RawExpression("nullptr") + pairs.append(cg.StructInitializer(Substitution, ("from", end), ("to", end))) + return cg.shared_progmem_array( + "text_sensor_substitutions", Substitution, cg.ArrayInitializer(*pairs) ) +@FILTER_REGISTRY.register( + "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) +) @FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping)) -async def map_filter_to_code(config, filter_id): - mappings = [ - cg.StructInitializer( - cg.MockObj("Substitution", "esphome::text_sensor::"), - ("from", conf[CONF_FROM]), - ("to", conf[CONF_TO]), - ) - for conf in config - ] - return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(mappings)), mappings) +async def substitution_filter_to_code( + config: list[ConfigType], filter_id: ID +) -> MockObj: + return cg.new_Pvariable(filter_id, _substitution_table(config)) validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_") @@ -205,7 +204,8 @@ async def setup_text_sensor_core_(var, config): if config.get(CONF_FILTERS): # must exist and not be empty cg.add_define("USE_TEXT_SENSOR_FILTER") filters = await build_filters(config[CONF_FILTERS]) - cg.add(var.set_filters(filters)) + for filter_ in filters: + cg.add(var.add_filter(filter_)) CORE.add_job(_build_text_sensor_automations, var, config) @@ -231,6 +231,13 @@ 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) diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index d4e6b5b9bb..54457d8238 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -8,7 +8,7 @@ namespace esphome::text_sensor { -static const char *const TAG = "text_sensor.filter"; +ESPHOME_LOG_TAG(TAG, "text_sensor.filter"); // Filter void Filter::input(std::string value) { @@ -73,25 +73,25 @@ bool PrependFilter::new_value(std::string &value) { return true; } -// Substitute — non-template helper -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - const size_t from_len = strlen(substitutions[i].from); - const size_t to_len = strlen(substitutions[i].to); +// Substitute +bool SubstituteFilter::new_value(std::string &value) { + for (const Substitution *s = this->substitutions_; s->from != nullptr; s++) { + const size_t from_len = strlen(s->from); + const size_t to_len = strlen(s->to); std::size_t pos = 0; - while ((pos = value.find(substitutions[i].from, pos, from_len)) != std::string::npos) { - value.replace(pos, from_len, substitutions[i].to, to_len); + while ((pos = value.find(s->from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, s->to, to_len); pos += to_len; } } return true; } -// Map — non-template helper -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value) { - for (size_t i = 0; i < count; i++) { - if (value == mappings[i].from) { - value.assign(mappings[i].to); +// Map +bool MapFilter::new_value(std::string &value) { + for (const Substitution *m = this->mappings_; m->from != nullptr; m++) { + if (value == m->from) { + value.assign(m->to); return true; } } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 6db76dcb64..7898d46686 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -3,8 +3,6 @@ #include "esphome/core/defines.h" #ifdef USE_TEXT_SENSOR_FILTER -#include - #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -123,25 +121,18 @@ struct Substitution { const char *to; }; -/// Non-template helper (implementation in filter.cpp) -bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value); - /// A simple filter that replaces a substring with another substring. -/// N is set by code generation to match the exact number of substitutions configured in YAML. -template class SubstituteFilter : public Filter { +/// `substitutions` is a codegen table in flash, shared by filters with the same list and +/// ended by an entry whose `from` is nullptr, so no count is stored. +class SubstituteFilter : public Filter { public: - explicit SubstituteFilter(const std::initializer_list &substitutions) { - init_array_from(this->substitutions_, substitutions); - } - bool new_value(std::string &value) override { return substitute_filter_apply(this->substitutions_.data(), N, value); } + explicit SubstituteFilter(const Substitution *substitutions) : substitutions_(substitutions) {} + bool new_value(std::string &value) override; protected: - std::array substitutions_{}; + const Substitution *substitutions_; }; -/// Non-template helper (implementation in filter.cpp) -bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value); - /** A filter that maps values from one set to another * * Uses linear search instead of std::map for typical small datasets (2-20 mappings). @@ -166,17 +157,16 @@ bool map_filter_apply(const Substitution *mappings, size_t count, std::string &v * * Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20 * - * N is set by code generation to match the exact number of mappings configured in YAML. + * `mappings` is a codegen table in flash, shared by filters with the same list and ended by + * an entry whose `from` is nullptr, so no count is stored. */ -template class MapFilter : public Filter { +class MapFilter : public Filter { public: - explicit MapFilter(const std::initializer_list &mappings) { - init_array_from(this->mappings_, mappings); - } - bool new_value(std::string &value) override { return map_filter_apply(this->mappings_.data(), N, value); } + explicit MapFilter(const Substitution *mappings) : mappings_(mappings) {} + bool new_value(std::string &value) override; protected: - std::array mappings_{}; + const Substitution *mappings_; }; } // namespace esphome::text_sensor diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 17c606d253..af4c5fd081 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::text_sensor { -static const char *const TAG = "text_sensor"; +ESPHOME_LOG_TAG(TAG, "text_sensor"); void log_text_sensor(const char *tag, const char *prefix, const char *type, TextSensor *obj) { if (obj == nullptr) { diff --git a/esphome/components/tfluna/__init__.py b/esphome/components/tfluna/__init__.py new file mode 100644 index 0000000000..6914894ba7 --- /dev/null +++ b/esphome/components/tfluna/__init__.py @@ -0,0 +1,35 @@ +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"] +DOMAIN = "tfluna" +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..e3d7045d8e --- /dev/null +++ b/esphome/components/tfluna/tfluna.cpp @@ -0,0 +1,160 @@ +#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; +ESPHOME_LOG_TAG(TAG, "tfluna"); + +static constexpr uint32_t SETUP_TIMEOUT_ID = 0; +static constexpr uint32_t READ_RETRY_TIMEOUT_ID = 1; + +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_ID, 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_ID); + 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_TIMEOUT_ID, 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/__init__.py b/esphome/components/thermopro_ble/__init__.py index e69de29bb2..f2f59ccbc2 100644 --- a/esphome/components/thermopro_ble/__init__.py +++ b/esphome/components/thermopro_ble/__init__.py @@ -0,0 +1 @@ +DOMAIN = "thermopro_ble" 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/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp index d10a6c33cd..38a58137ae 100644 --- a/esphome/components/thermopro_ble/thermopro_ble.cpp +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -19,7 +19,7 @@ static optional parse_tp972(const uint8_t *data, std::size_t data_s static optional parse_tp96(const uint8_t *data, std::size_t data_size); static optional parse_tp3(const uint8_t *data, std::size_t data_size); -static const char *const TAG = "thermopro_ble"; +ESPHOME_LOG_TAG(TAG, "thermopro_ble"); static const struct DeviceParserMapping DEVICE_PARSER_MAP[] = { {"TP972", parse_tp972}, {"TP970", parse_tp96}, {"TP96", parse_tp96}, {"TP3", parse_tp3}}; diff --git a/esphome/components/thermostat/__init__.py b/esphome/components/thermostat/__init__.py index e69de29bb2..7bed41dc56 100644 --- a/esphome/components/thermostat/__init__.py +++ b/esphome/components/thermostat/__init__.py @@ -0,0 +1 @@ +DOMAIN = "thermostat" diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index 3cc4dc7009..8a954b181e 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -39,7 +39,6 @@ from esphome.const import ( CONF_HEAT_MODE, CONF_HEAT_OVERRUN, CONF_HUMIDITY_SENSOR, - CONF_ID, CONF_IDLE_ACTION, CONF_MAX_COOLING_RUN_TIME, CONF_MAX_HEATING_RUN_TIME, @@ -72,6 +71,7 @@ from esphome.const import ( CONF_TARGET_TEMPERATURE_CHANGE_ACTION, CONF_VISUAL, ) +from esphome.helpers import cpp_string_escape from esphome.types import ConfigType CONF_DEFAULT_PRESET = "default_preset" @@ -93,6 +93,8 @@ ThermostatClimate = thermostat_ns.class_( ThermostatClimateTargetTempConfig = thermostat_ns.struct( "ThermostatClimateTargetTempConfig" ) +ThermostatPresetEntry = thermostat_ns.struct("ThermostatPresetEntry") +ThermostatCustomPresetEntry = thermostat_ns.struct("ThermostatCustomPresetEntry") OnBootRestoreFrom = thermostat_ns.enum("OnBootRestoreFrom") ON_BOOT_RESTORE_FROM = { "MEMORY": OnBootRestoreFrom.MEMORY, @@ -116,6 +118,7 @@ ClimatePreset = climate_ns.enum("ClimatePreset") PRESET_CONFIG_SCHEMA = cv.Schema( { + # Still accepted for existing configs; presets are flash table entries, not variables. cv.GenerateID(): cv.declare_id(ThermostatClimateTargetTempConfig), cv.Required(CONF_NAME): cv.string_strict, cv.Optional(CONF_MODE): validate_climate_mode, @@ -674,7 +677,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_LOW): cv.temperature, } ), - cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA), + cv.Optional(CONF_PRESET): cv.All( + cv.ensure_list(PRESET_CONFIG_SCHEMA), cv.Length(max=255) + ), cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from, cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation( single=True @@ -964,85 +969,51 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) if CONF_PRESET in config: - # Separate standard and custom presets, and build preset config variables - standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = [] - custom_presets: list[tuple[str, cg.MockObj]] = [] + # Presets live in shared PROGMEM tables; the thermostat keeps a pointer and a count. + standard_presets: list[cg.StructInitializer] = [] + custom_presets: list[cg.StructInitializer] = [] + nan = float("nan") + unset = cg.RawExpression("std::nullopt") for preset_config in config[CONF_PRESET]: name = preset_config[CONF_NAME] - standard_preset = None - if name.upper() in climate.CLIMATE_PRESETS: - standard_preset = climate.CLIMATE_PRESETS[name.upper()] - + low = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_LOW) + high = preset_config.get(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH) if two_points_available: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW], - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH], - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_HIGH in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_HIGH] - ) - elif CONF_DEFAULT_TARGET_TEMPERATURE_LOW in preset_config: - preset_target_config = ThermostatClimateTargetTempConfig( - preset_config[CONF_DEFAULT_TARGET_TEMPERATURE_LOW] + temperatures = (nan, low, high) + else: + temperatures = (high if high is not None else low, nan, nan) + target = ThermostatClimateTargetTempConfig( + *(nan if t is None else t for t in temperatures), + preset_config.get(CONF_MODE, unset), + preset_config.get(CONF_FAN_MODE, unset), + preset_config.get(CONF_SWING_MODE, unset), + ) + if (standard := climate.CLIMATE_PRESETS.get(name.upper())) is not None: + standard_presets.append( + cg.StructInitializer( + ThermostatPresetEntry, ("preset", standard), ("config", target) + ) ) else: - preset_target_config = None - - preset_target_variable = cg.new_variable( - preset_config[CONF_ID], preset_target_config - ) - - if CONF_MODE in preset_config: - cg.add(preset_target_variable.set_mode(preset_config[CONF_MODE])) - - if CONF_FAN_MODE in preset_config: - cg.add( - preset_target_variable.set_fan_mode(preset_config[CONF_FAN_MODE]) - ) - - if CONF_SWING_MODE in preset_config: - cg.add( - preset_target_variable.set_swing_mode( - preset_config[CONF_SWING_MODE] + custom_presets.append( + cg.StructInitializer( + ThermostatCustomPresetEntry, + ("name", cg.RawExpression(cpp_string_escape(name))), + ("config", target), ) ) - if standard_preset is not None: - standard_presets.append((standard_preset, preset_target_variable)) - else: - custom_presets.append((name, preset_target_variable)) - - # Build initializer list for standard presets if standard_presets: - cg.add( - var.set_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatPresetEntry"), - ("preset", preset), - ("config", preset_var), - ) - for preset, preset_var in standard_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_presets", ThermostatPresetEntry, standard_presets ) - - # Build initializer list for custom presets + cg.add(var.set_preset_config(table, len(standard_presets))) if custom_presets: - cg.add( - var.set_custom_preset_config( - [ - cg.StructInitializer( - thermostat_ns.struct("ThermostatCustomPresetEntry"), - ("name", cg.RawExpression(f'"{name}"')), - ("config", preset_var), - ) - for name, preset_var in custom_presets - ] - ) + table = cg.shared_progmem_array( + "thermostat_custom_presets", ThermostatCustomPresetEntry, custom_presets ) + cg.add(var.set_custom_preset_config(table, len(custom_presets))) if CONF_DEFAULT_PRESET in config: default_preset_name = config[CONF_DEFAULT_PRESET] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index f64673e13f..5d24759c2d 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -6,7 +6,7 @@ namespace esphome::thermostat { -static const char *const TAG = "thermostat.climate"; +ESPHOME_LOG_TAG(TAG, "thermostat.climate"); void ThermostatClimate::setup() { if (this->use_startup_delay_) { @@ -322,8 +322,8 @@ climate::ClimateTraits ThermostatClimate::traits() { if (this->supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); - for (const auto &entry : this->preset_config_) { - traits.add_supported_preset(entry.preset); + for (uint8_t i = 0; i < this->preset_count_; i++) { + traits.add_supported_preset(this->preset_entry_(i).preset); } // Custom presets are stored on Climate base class and wired via get_traits() @@ -1175,17 +1175,30 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm } } +ThermostatPresetEntry ThermostatClimate::preset_entry_(uint8_t index) const { + ThermostatPresetEntry entry; + progmem_memcpy(&entry, &this->preset_config_[index], sizeof(entry)); + return entry; +} + +ThermostatCustomPresetEntry ThermostatClimate::custom_preset_entry_(uint8_t index) const { + ThermostatCustomPresetEntry entry; + progmem_memcpy(&entry, &this->custom_preset_config_[index], sizeof(entry)); + return entry; +} + void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { // Linear search through preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->preset_config_) { + optional config; + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); if (entry.preset == preset) { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset))); if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) { // Fire preset changed trigger @@ -1205,16 +1218,17 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { void ThermostatClimate::change_custom_preset_(const char *custom_preset, size_t len) { // Linear search through custom preset configurations - const ThermostatClimateTargetTempConfig *config = nullptr; - for (const auto &entry : this->custom_preset_config_) { + optional config; + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); // Compare first len chars, then verify entry.name ends there (same length) if (strncmp(entry.name, custom_preset, len) == 0 && entry.name[len] == '\0') { - config = &entry.config; + config = entry.config; break; } } - if (config != nullptr) { + if (config.has_value()) { ESP_LOGV(TAG, "Custom preset %s requested", custom_preset); if (this->change_preset_internal_(*config) || !this->has_custom_preset() || this->get_custom_preset() != custom_preset) { @@ -1279,13 +1293,14 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { +void ThermostatClimate::set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count) { this->custom_preset_config_ = presets; + this->custom_preset_count_ = count; // Populate Climate base class custom presets vector std::vector names; - names.reserve(presets.size()); - for (const auto &entry : this->custom_preset_config_) { - names.push_back(entry.name); + names.reserve(count); + for (uint8_t i = 0; i < count; i++) { + names.push_back(this->custom_preset_entry_(i).name); } this->set_supported_custom_presets(names); } @@ -1294,7 +1309,8 @@ ThermostatClimate::ThermostatClimate() = default; void ThermostatClimate::set_default_preset(const char *custom_preset) { // Find the preset in custom_preset_config_ and store pointer from there - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); if (strcmp(entry.name, custom_preset) == 0) { this->default_custom_preset_ = entry.name; return; @@ -1545,9 +1561,10 @@ void ThermostatClimate::dump_config() { YESNO(this->supports_dehumidification_ || this->supports_humidification_), YESNO(this->supports_dehumidification_), YESNO(this->supports_humidification_)); - if (!this->preset_config_.empty()) { + if (this->preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported PRESETS:"); - for (const auto &entry : this->preset_config_) { + for (uint8_t i = 0; i < this->preset_count_; i++) { + auto entry = this->preset_entry_(i); const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset)); ESP_LOGCONFIG(TAG, " %s:%s", preset_name, entry.preset == this->default_preset_ ? LOG_STR_LITERAL(" (default)") : ""); @@ -1555,9 +1572,10 @@ void ThermostatClimate::dump_config() { } } - if (!this->custom_preset_config_.empty()) { + if (this->custom_preset_count_ > 0) { ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:"); - for (const auto &entry : this->custom_preset_config_) { + for (uint8_t i = 0; i < this->custom_preset_count_; i++) { + auto entry = this->custom_preset_entry_(i); const auto *preset_name = entry.name; ESP_LOGCONFIG(TAG, " %s:%s", preset_name, (this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0) @@ -1568,13 +1586,4 @@ void ThermostatClimate::dump_config() { } } -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig() = default; - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature) - : default_temperature(default_temperature) {} - -ThermostatClimateTargetTempConfig::ThermostatClimateTargetTempConfig(float default_temperature_low, - float default_temperature_high) - : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} - } // namespace esphome::thermostat diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index b7d46eae22..e9c0ae75fd 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -49,9 +49,21 @@ struct ThermostatClimateTimer { struct ThermostatClimateTargetTempConfig { public: - ThermostatClimateTargetTempConfig(); - ThermostatClimateTargetTempConfig(float default_temperature); - ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high); + constexpr ThermostatClimateTargetTempConfig() = default; + constexpr ThermostatClimateTargetTempConfig(float default_temperature) : default_temperature(default_temperature) {} + constexpr ThermostatClimateTargetTempConfig(float default_temperature_low, float default_temperature_high) + : default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {} + /// Full form used by the generated preset tables in flash. + constexpr ThermostatClimateTargetTempConfig(float default_temperature, float default_temperature_low, + float default_temperature_high, optional mode, + optional fan_mode, + optional swing_mode) + : default_temperature(default_temperature), + default_temperature_low(default_temperature_low), + default_temperature_high(default_temperature_high), + fan_mode_(fan_mode), + swing_mode_(swing_mode), + mode_(mode) {} void set_fan_mode(climate::ClimateFanMode fan_mode) { this->fan_mode_ = fan_mode; } void set_swing_mode(climate::ClimateSwingMode swing_mode) { this->swing_mode_ = swing_mode; } @@ -60,10 +72,6 @@ struct ThermostatClimateTargetTempConfig { float default_temperature{NAN}; float default_temperature_low{NAN}; float default_temperature_high{NAN}; - float cool_deadband_{NAN}; - float cool_overrun_{NAN}; - float heat_deadband_{NAN}; - float heat_overrun_{NAN}; optional fan_mode_{}; optional swing_mode_{}; optional mode_{}; @@ -175,8 +183,12 @@ class ThermostatClimate final : public climate::Climate, public Component { void set_supports_humidification(bool supports_humidification); void set_supports_two_points(bool supports_two_points) { this->supports_two_points_ = supports_two_points; } - void set_preset_config(std::initializer_list presets) { this->preset_config_ = presets; } - void set_custom_preset_config(std::initializer_list presets); + /// `presets` is a PROGMEM table that outlives the thermostat. + void set_preset_config(const PresetEntry *presets, uint8_t count) { + this->preset_config_ = presets; + this->preset_count_ = count; + } + void set_custom_preset_config(const CustomPresetEntry *presets, uint8_t count); Trigger<> *get_cool_action_trigger(); Trigger<> *get_supplemental_cool_action_trigger(); @@ -328,6 +340,9 @@ class ThermostatClimate final : public climate::Climate, public Component { bool humidification_required_(); void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config); + /// Copies of table entries; the tables live in PROGMEM and hold byte-sized fields. + PresetEntry preset_entry_(uint8_t index) const; + CustomPresetEntry custom_preset_entry_(uint8_t index) const; /// Minimum allowable duration in seconds for action timers const uint8_t min_timer_duration_{1}; @@ -349,6 +364,9 @@ class ThermostatClimate final : public climate::Climate, public Component { /// state will attempt to be restored if possible OnBootRestoreFrom on_boot_restore_from_{OnBootRestoreFrom::MEMORY}; + uint8_t preset_count_{0}; + uint8_t custom_preset_count_{0}; + /// Whether the controller supports auto/cooling/drying/fanning/heating. /// /// A false value for any given attribute means that the controller has no such action @@ -522,13 +540,13 @@ class ThermostatClimate final : public climate::Climate, public Component { /// Climate action timers std::array timer_{}; - /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) - FixedVector preset_config_{}; - /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") - FixedVector custom_preset_config_{}; + /// Standard preset configurations (Eg. AWAY, ECO, etc), a PROGMEM table of preset_count_ entries + const PresetEntry *preset_config_{nullptr}; + /// Custom preset configurations (eg. "My Custom Preset"), a PROGMEM table of custom_preset_count_ entries + const CustomPresetEntry *custom_preset_config_{nullptr}; private: - /// Default custom preset to use on start up (pointer to entry in custom_preset_config_) + /// Default custom preset to use on start up (name pointer from an entry in custom_preset_config_) const char *default_custom_preset_{nullptr}; }; diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index cedb9b3c9b..2ad3cd9c31 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -26,6 +26,8 @@ from esphome.const import ( CONF_SECONDS, CONF_TIMEZONE, CONF_TRIGGER_ID, + KEY_CORE, + KEY_FRAMEWORK_VERSION, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -46,7 +48,6 @@ DOMAIN = "time" 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) # C++ types for pre-parsed timezone struct generation DSTRuleType_cpp = time_ns.enum("DSTRuleType", is_class=True) @@ -357,11 +358,7 @@ TIME_SCHEMA = cv.Schema( }, validate_cron_keys, ), - cv.Optional(CONF_ON_TIME_SYNC): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(SyncTrigger), - } - ), + cv.Optional(CONF_ON_TIME_SYNC): automation.validate_automation({}), } ).extend( # ``visibility=ADVANCED`` flags the inherited ``update_interval`` @@ -424,8 +421,7 @@ async def setup_time_core_(time_var, config): _emit_parsed_timezone_fields(parsed) on_time = config.get(CONF_ON_TIME, []) - on_time_sync = config.get(CONF_ON_TIME_SYNC, []) - if on_time or on_time_sync: + if on_time: cg.add_define("USE_TIME_TRIGGERS") for conf in on_time: @@ -447,11 +443,10 @@ async def setup_time_core_(time_var, config): await cg.register_component(trigger, conf) await automation.build_automation(trigger, [], conf) - for conf in on_time_sync: - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], time_var) - - await cg.register_component(trigger, conf) - await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_TIME_SYNC, []): + await automation.build_callback_automation( + time_var, "add_on_time_sync_callback", [], conf + ) async def register_time(time_var, config): @@ -462,6 +457,12 @@ async def register_time(time_var, config): async def to_code(config): if CORE.using_zephyr: zephyr_add_prj_conf("POSIX_CLOCK", True) + # From sdk-nrf 2.8.0 newlib time() gets the clock through gettimeofday(), + # which Zephyr only provides with XSI_SINGLE_PROCESS. Without it time() + # always returns -1. + if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(2, 8, 0): + zephyr_add_prj_conf("POSIX_SINGLE_PROCESS", True) + zephyr_add_prj_conf("XSI_SINGLE_PROCESS", True) cg.add_define("USE_TIME") cg.add_global(time_ns.using) @@ -478,8 +479,8 @@ automation.register_apply_condition( # posix_tz.cpp is fully #ifdef'd on USE_TIME_TIMEZONE, set only when a -# timezone is configured or detected; automation.cpp holds the on_time and -# on_time_sync triggers and is #ifdef'd on USE_TIME_TRIGGERS. +# timezone is configured or detected; automation.cpp holds the on_time +# trigger and is #ifdef'd on USE_TIME_TRIGGERS. FILTER_SOURCE_FILES = filter_source_files_from_defines( { "posix_tz.cpp": "USE_TIME_TIMEZONE", diff --git a/esphome/components/time/automation.cpp b/esphome/components/time/automation.cpp index b91f8b0360..1c3d462566 100644 --- a/esphome/components/time/automation.cpp +++ b/esphome/components/time/automation.cpp @@ -7,7 +7,7 @@ namespace esphome::time { -static const char *const TAG = "automation"; +ESPHOME_LOG_TAG(TAG, "automation"); static const int MAX_TIMESTAMP_DRIFT = 900; // how far can the clock drift before we consider // there has been a drastic time synchronization @@ -94,10 +94,6 @@ void CronTrigger::add_days_of_week(const std::vector &days_of_week) { } float CronTrigger::get_setup_priority() const { return setup_priority::HARDWARE; } -SyncTrigger::SyncTrigger(RealTimeClock *rtc) : rtc_(rtc) { - rtc->add_on_time_sync_callback([this]() { this->trigger(); }); -} - } // namespace esphome::time #endif // USE_TIME_TRIGGERS diff --git a/esphome/components/time/automation.h b/esphome/components/time/automation.h index 5f980690a4..5e70000ac3 100644 --- a/esphome/components/time/automation.h +++ b/esphome/components/time/automation.h @@ -43,14 +43,6 @@ class CronTrigger final : public Trigger<>, public Component { RealTimeClock *rtc_; optional last_check_; }; - -class SyncTrigger final : public Trigger<>, public Component { - public: - explicit SyncTrigger(RealTimeClock *rtc); - - protected: - RealTimeClock *rtc_; -}; } // namespace esphome::time #endif // USE_TIME_TRIGGERS diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 4f32560525..5f6b7d150f 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -19,7 +19,7 @@ namespace esphome::time { -static const char *const TAG = "time"; +ESPHOME_LOG_TAG(TAG, "time"); RealTimeClock::RealTimeClock() = default; diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index 90994d90f4..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)); diff --git a/esphome/components/time_based/__init__.py b/esphome/components/time_based/__init__.py index ce2f453bda..39ec2d9a8d 100644 --- a/esphome/components/time_based/__init__.py +++ b/esphome/components/time_based/__init__.py @@ -1,3 +1,5 @@ import esphome.codegen as cg +DOMAIN = "time_based" + time_based_ns = cg.esphome_ns.namespace("time_based") diff --git a/esphome/components/time_based/cover/time_based_cover.cpp b/esphome/components/time_based/cover/time_based_cover.cpp index 613b190cf3..4be933dd46 100644 --- a/esphome/components/time_based/cover/time_based_cover.cpp +++ b/esphome/components/time_based/cover/time_based_cover.cpp @@ -5,7 +5,7 @@ namespace esphome::time_based { -static const char *const TAG = "time_based.cover"; +ESPHOME_LOG_TAG(TAG, "time_based.cover"); using namespace esphome::cover; diff --git a/esphome/components/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 7ad88d3018..92a8e439d2 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -10,14 +10,14 @@ from esphome.components.esp32 import ( add_idf_component, add_idf_sdkconfig_option, ) +from esphome.components.logger import DOMAIN as LOGGER_DOMAIN import esphome.config_validation as cv from esphome.const import CONF_HARDWARE_UART, CONF_ID -from esphome.core import ID -from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] CONFLICTS_WITH = ["usb_host"] +DOMAIN = "tinyusb" CONF_ON_MOUNT = "on_mount" CONF_ON_UNMOUNT = "on_unmount" @@ -34,11 +34,10 @@ CONF_VBUS_MONITOR_PIN = "vbus_monitor_pin" # esp_tinyusb's driver install (descriptors_set fails with no class and no # user-provided full_speed_config), which trips the task watchdog before # loop() ever runs. -_USB_CLASS_COMPONENTS = ("usb_cdc_acm",) +_USB_CLASS_COMPONENTS = ("usb_cdc_acm", "tinyusb_keyboard") tinyusb_ns = cg.esphome_ns.namespace("tinyusb") TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component) -IsMountedCondition = tinyusb_ns.class_("IsMountedCondition", automation.Condition) _CALLBACK_AUTOMATIONS = ( automation.CallbackAutomation( @@ -98,7 +97,7 @@ def _final_validate(config: ConfigType) -> None: # tinyusb owns the USB OTG peripheral. The logger's USB_CDC backend routes # the ROM console through that same peripheral, so the two cannot coexist. # (USB_SERIAL_JTAG is a separate peripheral and is fine alongside tinyusb.) - logger_config = full_config.get("logger") + logger_config = full_config.get(LOGGER_DOMAIN) if logger_config and logger_config.get(CONF_HARDWARE_UART) == "USB_CDC": raise cv.Invalid( "'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' " @@ -136,16 +135,8 @@ async def to_code(config: ConfigType) -> None: add_idf_sdkconfig_option("CONFIG_TINYUSB_DESC_BCD_DEVICE", 0x0100) -@automation.register_condition( +automation.register_apply_condition( "tinyusb.is_mounted", - IsMountedCondition, cv.Schema({cv.GenerateID(): cv.use_id(TinyUSB)}), + "is_mounted()", ) -async def tinyusb_is_mounted_to_code( - config: ConfigType, - condition_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> MockObj: - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(condition_id, template_arg, paren) diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index 3fab9de008..ba2fbded92 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -7,7 +7,7 @@ namespace esphome::tinyusb { -static const char *const TAG = "tinyusb"; +ESPHOME_LOG_TAG(TAG, "tinyusb"); // Runs on the TinyUSB task: only wake the main loop, which reads the state and runs // the automations. @@ -26,6 +26,8 @@ void TinyUSB::setup() { } // Start from esp_tinyusb defaults to keep required task settings valid across esp_tinyusb updates. + // Setters write into tusb_cfg_ before setup(), so anything they set has to survive the reset. + const uint8_t *full_speed_config = this->tusb_cfg_.descriptor.full_speed_config; this->tusb_cfg_ = TINYUSB_DEFAULT_CONFIG(); this->tusb_cfg_.port = TINYUSB_PORT_FULL_SPEED_0; this->tusb_cfg_.phy.skip_setup = false; @@ -39,6 +41,7 @@ void TinyUSB::setup() { .device = &this->usb_descriptor_, .string = this->string_descriptor_, .string_count = SIZE, + .full_speed_config = full_speed_config, }; // Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index f7f574ec6d..e9f7283b01 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -1,7 +1,6 @@ #pragma once #if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) -#include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -50,6 +49,8 @@ class TinyUSB final : public Component { 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); } + /// Configuration descriptor from a class component that esp_tinyusb has no built-in default for (HID). + void set_full_speed_config(const uint8_t *descriptor) { this->tusb_cfg_.descriptor.full_speed_config = descriptor; } protected: char usb_desc_lang_id_[2] = {0x09, 0x04}; // defaults to english @@ -87,15 +88,6 @@ class TinyUSB final : public Component { }; }; -template class IsMountedCondition final : public Condition { - public: - explicit IsMountedCondition(TinyUSB *parent) : parent_(parent) {} - bool check(const Ts &...) override { return this->parent_->is_mounted(); } - - protected: - TinyUSB *parent_; -}; - } // namespace esphome::tinyusb #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb_keyboard/__init__.py b/esphome/components/tinyusb_keyboard/__init__.py new file mode 100644 index 0000000000..18b49a95bd --- /dev/null +++ b/esphome/components/tinyusb_keyboard/__init__.py @@ -0,0 +1,110 @@ +import string + +from esphome import automation +import esphome.codegen as cg +from esphome.components import esp32 +from esphome.components.esp32 import ( + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + add_idf_sdkconfig_option, +) +from esphome.components.tinyusb import TinyUSB +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_KEY +from esphome.types import ConfigType + +CODEOWNERS = ["@frederikbolding"] +# The keyboard supplies the whole configuration descriptor, with no room for the CDC interfaces +CONFLICTS_WITH = ["usb_cdc_acm"] +DEPENDENCIES = ["tinyusb"] +DOMAIN = "tinyusb_keyboard" + +CONF_MODIFIERS = "modifiers" +CONF_TINYUSB_ID = "tinyusb_id" +CONF_USAGE = "usage" + +# Keyboard page usage ids run without gaps from HID_KEY_A (0x04) through a..z, 1..9 and 0 +HID_KEY_A = 0x04 +KEY_CODES = { + char: HID_KEY_A + index + for index, char in enumerate(string.ascii_lowercase + "1234567890") +} + +tinyusb_keyboard_ns = cg.esphome_ns.namespace("tinyusb_keyboard") +TinyUSBKeyboard = tinyusb_keyboard_ns.class_("TinyUSBKeyboard", cg.Component) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(TinyUSBKeyboard), + cv.GenerateID(CONF_TINYUSB_ID): cv.use_id(TinyUSB), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[ + VARIANT_ESP32H4, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + VARIANT_ESP32S31, + ], + ), +) + +_KEY_CODE = cv.Any(cv.enum(KEY_CODES), cv.int_range(min=10, max=255)) + + +def key_code(value: object) -> int: + """A single lowercase letter or digit, or a keyboard usage id. + + Usage ids 0 to 9 are never typed keys (error codes and the letters a to f), so an unquoted + digit is read as that digit. + """ + if isinstance(value, int) and 0 <= value <= 9: + value = str(value) + return _KEY_CODE(value) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + tinyusb = await cg.get_variable(config[CONF_TINYUSB_ID]) + cg.add(tinyusb.set_full_speed_config(tinyusb_keyboard_ns.CONFIGURATION_DESCRIPTOR)) + add_idf_sdkconfig_option("CONFIG_TINYUSB_HID_COUNT", 1) + + +KEYBOARD_ID_SCHEMA = {cv.GenerateID(): cv.use_id(TinyUSBKeyboard)} + +automation.register_apply_action( + "tinyusb_keyboard.press", + automation.maybe_simple_id( + { + **KEYBOARD_ID_SCHEMA, + cv.Required(CONF_KEY): cv.templatable(key_code), + cv.Optional(CONF_MODIFIERS, default=0): cv.templatable(cv.uint8_t), + } + ), + automation.ApplyCall( + "press_key({}, {})", ((CONF_KEY, cg.uint8), (CONF_MODIFIERS, cg.uint8)) + ), +) +automation.register_apply_action( + "tinyusb_keyboard.release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_keys()"), +) +automation.register_apply_action( + "tinyusb_keyboard.media_press", + automation.maybe_simple_id( + {**KEYBOARD_ID_SCHEMA, cv.Required(CONF_USAGE): cv.templatable(cv.uint16_t)} + ), + automation.ApplyCall("press_media({})", ((CONF_USAGE, cg.uint16),)), +) +automation.register_apply_action( + "tinyusb_keyboard.media_release", + automation.maybe_simple_id(KEYBOARD_ID_SCHEMA), + automation.ApplyCall("release_media()"), +) diff --git a/esphome/components/tinyusb_keyboard/keyboard.cpp b/esphome/components/tinyusb_keyboard/keyboard.cpp new file mode 100644 index 0000000000..6d2be7f7eb --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.cpp @@ -0,0 +1,90 @@ +#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 "keyboard.h" +#include "esphome/core/log.h" + +namespace esphome::tinyusb_keyboard { + +ESPHOME_LOG_TAG(TAG, "tinyusb_keyboard"); + +void TinyUSBKeyboard::dump_config() { ESP_LOGCONFIG(TAG, "TinyUSB Keyboard"); } + +void TinyUSBKeyboard::set_keyboard_(uint8_t keycode, uint8_t modifiers) { + this->keycode_ = keycode; + this->modifiers_ = modifiers; + this->keyboard_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::set_consumer_(uint16_t usage) { + this->usage_ = usage; + this->consumer_pending_ = true; + this->flush_(); +} + +void TinyUSBKeyboard::flush_() { + if (!tud_mounted()) { + // No host is listening, and a key held from before it attached would be wrong, so drop the state + ESP_LOGV(TAG, "No host, dropping report"); + this->keyboard_pending_ = false; + this->consumer_pending_ = false; + this->wakeup_requested_ = false; + this->disable_loop(); + return; + } + if (tud_suspended()) { + // A key press wakes a sleeping host, like a real keyboard; the report goes out once the bus resumes. + // Signal once per suspend: a host that did not arm remote wakeup will not answer, and one that did + // must not be signalled on every pass. + if (!this->wakeup_requested_) { + this->wakeup_requested_ = true; + if (!tud_remote_wakeup()) { + ESP_LOGV(TAG, "Host did not allow remote wakeup"); + } + } + this->enable_loop(); + return; + } + this->wakeup_requested_ = false; + // tud_hid_*_report() returns false while the endpoint still holds the previous report, which is + // what happens for a press and a release in one action list. Keep retrying so the release gets out. + if (this->keyboard_pending_) { + uint8_t keycodes[6] = {this->keycode_}; + if (tud_hid_keyboard_report(REPORT_ID_KEYBOARD, this->modifiers_, keycodes)) { + this->keyboard_pending_ = false; + } + } + if (this->consumer_pending_ && !this->keyboard_pending_ && + tud_hid_report(REPORT_ID_CONSUMER, &this->usage_, sizeof(this->usage_))) { + this->consumer_pending_ = false; + } + if (this->keyboard_pending_ || this->consumer_pending_) { + this->enable_loop(); + } else { + this->disable_loop(); + } +} + +void TinyUSBKeyboard::loop() { this->flush_(); } + +} // namespace esphome::tinyusb_keyboard + +// TinyUSB resolves these at link time when HID is enabled +extern "C" { +const uint8_t *tud_hid_descriptor_report_cb(uint8_t /*instance*/) { + return esphome::tinyusb_keyboard::HID_REPORT_DESCRIPTOR; +} + +uint16_t tud_hid_get_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + uint8_t * /*buffer*/, uint16_t /*reqlen*/) { + return 0; +} + +// Keyboard LED state from the host; nothing consumes it yet +void tud_hid_set_report_cb(uint8_t /*instance*/, uint8_t /*report_id*/, hid_report_type_t /*report_type*/, + const uint8_t * /*buffer*/, uint16_t /*bufsize*/) {} +} + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tinyusb_keyboard/keyboard.h b/esphome/components/tinyusb_keyboard/keyboard.h new file mode 100644 index 0000000000..4c86ee599c --- /dev/null +++ b/esphome/components/tinyusb_keyboard/keyboard.h @@ -0,0 +1,66 @@ +#pragma once + +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ + defined(USE_ESP32_VARIANT_ESP32S31) || defined(USE_ESP32_VARIANT_ESP32H4) + +#include "esphome/core/component.h" + +#include "tusb.h" +// tusb.h only pulls the HID class in when CFG_TUD_HID is set, which the static analysis build does not do +#include "class/hid/hid_device.h" + +namespace esphome::tinyusb_keyboard { + +static constexpr uint8_t REPORT_ID_KEYBOARD = 1; +static constexpr uint8_t REPORT_ID_CONSUMER = 2; +static constexpr uint8_t ENDPOINT_IN = 0x81; +static constexpr uint8_t POLL_INTERVAL_MS = 10; + +// One HID interface carrying a keyboard and a consumer control (media keys), told apart by report id. +// Report ids rule out the boot protocol, so the interface declares no boot protocol, as TinyUSB's +// composite example does. +inline constexpr uint8_t HID_REPORT_DESCRIPTOR[] = { + TUD_HID_REPORT_DESC_KEYBOARD(HID_REPORT_ID(REPORT_ID_KEYBOARD)), + TUD_HID_REPORT_DESC_CONSUMER(HID_REPORT_ID(REPORT_ID_CONSUMER)), +}; + +// esp_tinyusb ships default configuration descriptors for CDC, MSC and NCM only, so the keyboard supplies its own +inline constexpr uint8_t CONFIGURATION_DESCRIPTOR[] = { + TUD_CONFIG_DESCRIPTOR(1, 1, 0, TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100), + TUD_HID_DESCRIPTOR(0, 0, HID_ITF_PROTOCOL_NONE, sizeof(HID_REPORT_DESCRIPTOR), ENDPOINT_IN, CFG_TUD_HID_EP_BUFSIZE, + POLL_INTERVAL_MS), +}; + +class TinyUSBKeyboard final : public Component { + public: + void setup() override { this->disable_loop(); } + void loop() override; + void dump_config() override; + + /// Press one key (HID usage id) with the given modifier bitmask; every other key is reported released. + void press_key(uint8_t keycode, uint8_t modifiers) { this->set_keyboard_(keycode, modifiers); } + void release_keys() { this->set_keyboard_(0, 0); } + /// Press one consumer control usage, for example 0xE9 for volume up. + void press_media(uint16_t usage) { this->set_consumer_(usage); } + void release_media() { this->set_consumer_(0); } + + protected: + void set_keyboard_(uint8_t keycode, uint8_t modifiers); + void set_consumer_(uint16_t usage); + /// Sends whatever changed; a report the endpoint cannot take yet is retried from loop() + void flush_(); + + // The wanted state of each report, resent until the endpoint accepts it so a release is never lost. + // Only the latest state is kept, so consecutive key actions need a short delay between them. + uint16_t usage_{0}; + uint8_t keycode_{0}; + uint8_t modifiers_{0}; + bool keyboard_pending_{false}; + bool consumer_pending_{false}; + bool wakeup_requested_{false}; +}; + +} // namespace esphome::tinyusb_keyboard + +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || + // USE_ESP32_VARIANT_ESP32S31 || USE_ESP32_VARIANT_ESP32H4 diff --git a/esphome/components/tlc59208f/__init__.py b/esphome/components/tlc59208f/__init__.py index 1af9f66b95..5d719e40ba 100644 --- a/esphome/components/tlc59208f/__init__.py +++ b/esphome/components/tlc59208f/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType DEPENDENCIES = ["i2c"] +DOMAIN = "tlc59208f" MULTI_CONF = True tlc59208f_ns = cg.esphome_ns.namespace("tlc59208f") diff --git a/esphome/components/tlc59208f/tlc59208f_output.cpp b/esphome/components/tlc59208f/tlc59208f_output.cpp index def337befc..d76845b4f0 100644 --- a/esphome/components/tlc59208f/tlc59208f_output.cpp +++ b/esphome/components/tlc59208f/tlc59208f_output.cpp @@ -5,7 +5,7 @@ namespace esphome::tlc59208f { -static const char *const TAG = "tlc59208f"; +ESPHOME_LOG_TAG(TAG, "tlc59208f"); // * marks register defaults // 0*: Register auto increment disabled, 1: Register auto increment enabled diff --git a/esphome/components/tlc5947/__init__.py b/esphome/components/tlc5947/__init__.py index 05e2ef037d..71247dd13e 100644 --- a/esphome/components/tlc5947/__init__.py +++ b/esphome/components/tlc5947/__init__.py @@ -16,6 +16,7 @@ from esphome.types import ConfigType CONF_LAT_PIN = "lat_pin" CODEOWNERS = ["@rnauber"] +DOMAIN = "tlc5947" tlc5947_ns = cg.esphome_ns.namespace("tlc5947") TLC5947 = tlc5947_ns.class_("TLC5947", cg.Component) diff --git a/esphome/components/tlc5947/tlc5947.cpp b/esphome/components/tlc5947/tlc5947.cpp index f886118a08..5502679c9d 100644 --- a/esphome/components/tlc5947/tlc5947.cpp +++ b/esphome/components/tlc5947/tlc5947.cpp @@ -3,7 +3,7 @@ namespace esphome::tlc5947 { -static const char *const TAG = "tlc5947"; +ESPHOME_LOG_TAG(TAG, "tlc5947"); void TLC5947::setup() { this->data_pin_->setup(); diff --git a/esphome/components/tlc5971/__init__.py b/esphome/components/tlc5971/__init__.py index 5b9027e61a..26beec280e 100644 --- a/esphome/components/tlc5971/__init__.py +++ b/esphome/components/tlc5971/__init__.py @@ -8,6 +8,7 @@ from esphome.const import CONF_CLOCK_PIN, CONF_DATA_PIN, CONF_ID, CONF_NUM_CHIPS from esphome.types import ConfigType CODEOWNERS = ["@IJIJI"] +DOMAIN = "tlc5971" tlc5971_ns = cg.esphome_ns.namespace("tlc5971") TLC5971 = tlc5971_ns.class_("TLC5971", cg.Component) diff --git a/esphome/components/tlc5971/tlc5971.cpp b/esphome/components/tlc5971/tlc5971.cpp index 5818eace67..2c4ec56732 100644 --- a/esphome/components/tlc5971/tlc5971.cpp +++ b/esphome/components/tlc5971/tlc5971.cpp @@ -3,7 +3,7 @@ namespace esphome::tlc5971 { -static const char *const TAG = "tlc5971"; +ESPHOME_LOG_TAG(TAG, "tlc5971"); void TLC5971::setup() { this->data_pin_->setup(); diff --git a/esphome/components/tm1621/__init__.py b/esphome/components/tm1621/__init__.py index 2e88d4f366..1b78f353ca 100644 --- a/esphome/components/tm1621/__init__.py +++ b/esphome/components/tm1621/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@Philippe12"] +DOMAIN = "tm1621" diff --git a/esphome/components/tm1621/tm1621.cpp b/esphome/components/tm1621/tm1621.cpp index 68d16e3811..5ca1c83c48 100644 --- a/esphome/components/tm1621/tm1621.cpp +++ b/esphome/components/tm1621/tm1621.cpp @@ -5,7 +5,7 @@ namespace esphome::tm1621 { -static const char *const TAG = "tm1621"; +ESPHOME_LOG_TAG(TAG, "tm1621"); const uint8_t TM1621_PULSE_WIDTH = 10; // microseconds (Sonoff = 100) diff --git a/esphome/components/tm1637/__init__.py b/esphome/components/tm1637/__init__.py index e69de29bb2..c8b08abf03 100644 --- a/esphome/components/tm1637/__init__.py +++ b/esphome/components/tm1637/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tm1637" diff --git a/esphome/components/tm1637/tm1637.cpp b/esphome/components/tm1637/tm1637.cpp index a1604fa60e..03eb427e76 100644 --- a/esphome/components/tm1637/tm1637.cpp +++ b/esphome/components/tm1637/tm1637.cpp @@ -5,7 +5,7 @@ namespace esphome::tm1637 { -static const char *const TAG = "display.tm1637"; +ESPHOME_LOG_TAG(TAG, "display.tm1637"); const uint8_t TM1637_CMD_DATA = 0x40; //!< Display data command const uint8_t TM1637_CMD_CTRL = 0x80; //!< Display control command const uint8_t TM1637_CMD_ADDR = 0xc0; //!< Display address command diff --git a/esphome/components/tm1638/__init__.py b/esphome/components/tm1638/__init__.py index e69de29bb2..4500a10ff4 100644 --- a/esphome/components/tm1638/__init__.py +++ b/esphome/components/tm1638/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tm1638" diff --git a/esphome/components/tm1638/output/tm1638_output_led.cpp b/esphome/components/tm1638/output/tm1638_output_led.cpp index e32826fa93..8e2020459c 100644 --- a/esphome/components/tm1638/output/tm1638_output_led.cpp +++ b/esphome/components/tm1638/output/tm1638_output_led.cpp @@ -3,7 +3,7 @@ namespace esphome::tm1638 { -static const char *const TAG = "tm1638.led"; +ESPHOME_LOG_TAG(TAG, "tm1638.led"); void TM1638OutputLed::write_state(bool state) { tm1638_->set_led(led_, state); } diff --git a/esphome/components/tm1638/switch/tm1638_switch_led.cpp b/esphome/components/tm1638/switch/tm1638_switch_led.cpp index 743d0af507..f5573a3592 100644 --- a/esphome/components/tm1638/switch/tm1638_switch_led.cpp +++ b/esphome/components/tm1638/switch/tm1638_switch_led.cpp @@ -3,7 +3,7 @@ namespace esphome::tm1638 { -static const char *const TAG = "tm1638.led"; +ESPHOME_LOG_TAG(TAG, "tm1638.led"); void TM1638SwitchLed::write_state(bool state) { tm1638_->set_led(led_, state); diff --git a/esphome/components/tm1638/tm1638.cpp b/esphome/components/tm1638/tm1638.cpp index 1f0692479c..ffb0ead0cd 100644 --- a/esphome/components/tm1638/tm1638.cpp +++ b/esphome/components/tm1638/tm1638.cpp @@ -6,7 +6,7 @@ namespace esphome::tm1638 { -static const char *const TAG = "display.tm1638"; +ESPHOME_LOG_TAG(TAG, "display.tm1638"); static const uint8_t TM1638_REGISTER_FIXEDADDRESS = 0x44; static const uint8_t TM1638_REGISTER_AUTOADDRESS = 0x40; static const uint8_t TM1638_REGISTER_READBUTTONS = 0x42; diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index c0cc6f1d2c..14aa1c8842 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -9,11 +9,10 @@ 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"] +DOMAIN = "tm1651" CONF_LEVEL_PERCENT = "level_percent" @@ -21,11 +20,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 +60,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.cpp b/esphome/components/tm1651/tm1651.cpp index 282b0dcf76..467695b4b4 100644 --- a/esphome/components/tm1651/tm1651.cpp +++ b/esphome/components/tm1651/tm1651.cpp @@ -53,7 +53,7 @@ namespace esphome::tm1651 { -static const char *const TAG = "tm1651.display"; +ESPHOME_LOG_TAG(TAG, "tm1651.display"); static const bool LINE_HIGH = true; static const bool LINE_LOW = false; 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/tmc2209/__init__.py b/esphome/components/tmc2209/__init__.py new file mode 100644 index 0000000000..b3822f7582 --- /dev/null +++ b/esphome/components/tmc2209/__init__.py @@ -0,0 +1,10 @@ +import esphome.codegen as cg +from esphome.components import tmc22xx + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc2209" + +tmc2209_ns = cg.esphome_ns.namespace("tmc2209") +TMC2209Stepper = tmc2209_ns.class_("TMC2209Stepper", tmc22xx.TMC22XXStepper) + +tmc22xx.register_actions("tmc2209", TMC2209Stepper) diff --git a/esphome/components/tmc2209/stepper.py b/esphome/components/tmc2209/stepper.py new file mode 100644 index 0000000000..5ef91dc591 --- /dev/null +++ b/esphome/components/tmc2209/stepper.py @@ -0,0 +1,14 @@ +from esphome.components import tmc22xx +from esphome.types import ConfigType + +from . import TMC2209Stepper + +AUTO_LOAD = ["tmc22xx"] +DEPENDENCIES = ["uart"] + +CONFIG_SCHEMA = tmc22xx.tmc22xx_schema(TMC2209Stepper, max_address=3) +FINAL_VALIDATE_SCHEMA = tmc22xx.final_validate + + +async def to_code(config: ConfigType) -> None: + await tmc22xx.new_tmc22xx(config) diff --git a/esphome/components/tmc2209/tmc2209.cpp b/esphome/components/tmc2209/tmc2209.cpp new file mode 100644 index 0000000000..040b362a8d --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.cpp @@ -0,0 +1,13 @@ +#include "tmc2209.h" +#include "esphome/core/log.h" + +namespace esphome::tmc2209 { + +ESPHOME_LOG_TAG(TAG, "tmc2209"); + +void TMC2209Stepper::dump_config() { + ESP_LOGCONFIG(TAG, "TMC2209:"); + TMC22XXStepper::dump_config(); +} + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc2209/tmc2209.h b/esphome/components/tmc2209/tmc2209.h new file mode 100644 index 0000000000..2f880251e5 --- /dev/null +++ b/esphome/components/tmc2209/tmc2209.h @@ -0,0 +1,15 @@ +#pragma once + +#include "esphome/components/tmc22xx/tmc22xx.h" + +namespace esphome::tmc2209 { + +class TMC2209Stepper : public tmc22xx::TMC22XXStepper { + public: + void dump_config() override; + + protected: + uint8_t expected_version() const override { return 0x21; } +}; + +} // namespace esphome::tmc2209 diff --git a/esphome/components/tmc22xx/__init__.py b/esphome/components/tmc22xx/__init__.py new file mode 100644 index 0000000000..ad7221f0b2 --- /dev/null +++ b/esphome/components/tmc22xx/__init__.py @@ -0,0 +1,211 @@ +"""Shared code for Trinamic stepper drivers configured over the single wire UART interface.""" + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import stepper, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_ADDRESS, + CONF_DIR_PIN, + CONF_ID, + CONF_STEP_PIN, + CONF_UART_ID, +) +from esphome.cpp_generator import MockObjClass +import esphome.final_validate as fv +from esphome.types import ConfigType + +CODEOWNERS = ["@remcom"] +DOMAIN = "tmc22xx" +DEPENDENCIES = ["uart"] + +CONF_ANALOG_CURRENT_SCALE = "analog_current_scale" +CONF_CLOCK_FREQUENCY = "clock_frequency" +CONF_ENABLE_SPREADCYCLE = "enable_spreadcycle" +CONF_ENN_PIN = "enn_pin" +CONF_HOLD_CURRENT = "hold_current" +CONF_IHOLD = "ihold" +CONF_IHOLDDELAY = "iholddelay" +CONF_INDEX_PIN = "index_pin" +CONF_INTERPOLATION = "interpolation" +CONF_INVERSE_DIRECTION = "inverse_direction" +CONF_IRUN = "irun" +CONF_MICROSTEPS = "microsteps" +CONF_OTTRIM = "ottrim" +CONF_RSENSE = "rsense" +CONF_RUN_CURRENT = "run_current" +CONF_STANDSTILL_MODE = "standstill_mode" +CONF_TPOWERDOWN = "tpowerdown" +CONF_TPWM_THRESHOLD = "tpwm_threshold" +CONF_VSENSE = "vsense" + +tmc22xx_ns = cg.esphome_ns.namespace("tmc22xx") +TMC22XXStepper = tmc22xx_ns.class_( + "TMC22XXStepper", stepper.Stepper, cg.Component, uart.UARTDevice +) +StandstillMode = tmc22xx_ns.enum("StandstillMode") + +STANDSTILL_MODES = { + "normal": StandstillMode.STANDSTILL_MODE_NORMAL, + "freewheeling": StandstillMode.STANDSTILL_MODE_FREEWHEELING, + "coil_short_ls": StandstillMode.STANDSTILL_MODE_COIL_SHORT_LS, + "coil_short_hs": StandstillMode.STANDSTILL_MODE_COIL_SHORT_HS, +} + +validate_microsteps = cv.one_of(1, 2, 4, 8, 16, 32, 64, 128, 256, int=True) +validate_current = cv.All(cv.current, cv.positive_float) + + +def tmc22xx_schema(cls: MockObjClass, max_address: int) -> cv.Schema: + """Return the stepper schema for a family member; `max_address` is 0 for chips without address pins.""" + return cv.All( + stepper.STEPPER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(cls), + cv.Optional(CONF_ADDRESS, default=0): cv.int_range(0, max_address), + cv.Optional(CONF_ENN_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_STEP_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_DIR_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_INDEX_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_CLOCK_FREQUENCY, default="12MHz"): cv.All( + cv.frequency, cv.int_range(min=1) + ), + cv.Optional(CONF_RSENSE): cv.All( + cv.resistance, cv.float_range(min=0.0, min_included=False) + ), + cv.Optional(CONF_VSENSE): cv.boolean, + cv.Optional(CONF_OTTRIM): cv.int_range(0, 3), + cv.Optional(CONF_ANALOG_CURRENT_SCALE, default=False): cv.boolean, + cv.Optional(CONF_RUN_CURRENT): validate_current, + cv.Optional(CONF_HOLD_CURRENT): validate_current, + cv.Optional(CONF_MICROSTEPS, default=16): validate_microsteps, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + cv.has_exactly_one_key(CONF_INDEX_PIN, CONF_STEP_PIN), + cv.has_none_or_all_keys(CONF_STEP_PIN, CONF_DIR_PIN), + _validate_analog_current_scale, + ) + + +def _validate_analog_current_scale(config: ConfigType) -> ConfigType: + if config[CONF_ANALOG_CURRENT_SCALE]: + for key in (CONF_RUN_CURRENT, CONF_HOLD_CURRENT): + if key in config: + raise cv.Invalid( + f"'{key}' can't be used with '{CONF_ANALOG_CURRENT_SCALE}', the " + f"current then depends on VREF; set '{CONF_IRUN}'/'{CONF_IHOLD}' " + "with the currents action instead", + path=[key], + ) + return config + + +def final_validate(config: ConfigType) -> ConfigType: + """Reject two drivers of the family with the same address on one UART bus.""" + for other in fv.full_config.get().get("stepper", []): + if other is config: + break + if ( + other.get(CONF_UART_ID) == config[CONF_UART_ID] + and other.get(CONF_ADDRESS) == config[CONF_ADDRESS] + ): + raise cv.Invalid( + f"Address {config[CONF_ADDRESS]} is already used by '{other[CONF_ID]}' " + "on the same UART bus" + ) + return config + + +async def new_tmc22xx(config: ConfigType) -> cg.Pvariable: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + await stepper.register_stepper(var, config) + + cg.add(var.set_address(config[CONF_ADDRESS])) + cg.add(var.set_clock_frequency(config[CONF_CLOCK_FREQUENCY])) + cg.add(var.set_analog_current_scale(config[CONF_ANALOG_CURRENT_SCALE])) + cg.add(var.set_initial_microsteps(config[CONF_MICROSTEPS])) + for key, setter in ( + (CONF_ENN_PIN, var.set_enn_pin), + (CONF_STEP_PIN, var.set_step_pin), + (CONF_DIR_PIN, var.set_dir_pin), + (CONF_INDEX_PIN, var.set_index_pin), + ): + if (pin := config.get(key)) is not None: + cg.add(setter(await cg.gpio_pin_expression(pin))) + for key, setter in ( + (CONF_RSENSE, var.set_rsense), + (CONF_VSENSE, var.set_vsense), + (CONF_OTTRIM, var.set_ottrim), + (CONF_RUN_CURRENT, var.set_initial_run_current), + (CONF_HOLD_CURRENT, var.set_initial_hold_current), + ): + if (value := config.get(key)) is not None: + cg.add(setter(value)) + return var + + +def register_actions(domain: str, cls: MockObjClass) -> None: + """Register the actions shared by the family under `domain`, e.g. `tmc2209.enable`.""" + id_schema = {cv.GenerateID(): cv.use_id(cls)} + for name, enabled in (("enable", "true"), ("disable", "false")): + automation.register_apply_action( + f"{domain}.{name}", + automation.maybe_simple_id(id_schema), + automation.ApplyCall(f"set_enabled({enabled})"), + ) + + automation.register_apply_action( + f"{domain}.configure", + cv.Schema( + { + **id_schema, + cv.Optional(CONF_INVERSE_DIRECTION): cv.templatable(cv.boolean), + cv.Optional(CONF_MICROSTEPS): cv.templatable(validate_microsteps), + cv.Optional(CONF_INTERPOLATION): cv.templatable(cv.boolean), + cv.Optional(CONF_ENABLE_SPREADCYCLE): cv.templatable(cv.boolean), + cv.Optional(CONF_TPWM_THRESHOLD): cv.templatable( + cv.int_range(0, 2**20 - 1) + ), + } + ), + automation.ApplyField( + CONF_INVERSE_DIRECTION, "set_inverse_direction", cg.bool_ + ), + automation.ApplyField(CONF_MICROSTEPS, "set_microsteps", cg.uint16), + automation.ApplyField(CONF_INTERPOLATION, "set_interpolation", cg.bool_), + automation.ApplyField(CONF_ENABLE_SPREADCYCLE, "set_spreadcycle", cg.bool_), + automation.ApplyField(CONF_TPWM_THRESHOLD, "set_tpwm_threshold", cg.uint32), + ) + + automation.register_apply_action( + f"{domain}.currents", + cv.Schema( + { + **id_schema, + cv.Exclusive(CONF_RUN_CURRENT, "run"): cv.templatable(validate_current), + cv.Exclusive(CONF_IRUN, "run"): cv.templatable(cv.int_range(0, 31)), + cv.Exclusive(CONF_HOLD_CURRENT, "hold"): cv.templatable( + validate_current + ), + cv.Exclusive(CONF_IHOLD, "hold"): cv.templatable(cv.int_range(0, 31)), + cv.Optional(CONF_IHOLDDELAY): cv.templatable(cv.int_range(0, 15)), + cv.Optional(CONF_TPOWERDOWN): cv.templatable(cv.int_range(0, 255)), + cv.Optional(CONF_STANDSTILL_MODE): cv.templatable( + cv.enum(STANDSTILL_MODES, lower=True) + ), + } + ), + automation.ApplyField(CONF_RUN_CURRENT, "set_run_current", cg.float_), + automation.ApplyField(CONF_IRUN, "set_irun", cg.uint8), + automation.ApplyField(CONF_HOLD_CURRENT, "set_hold_current", cg.float_), + automation.ApplyField(CONF_IHOLD, "set_ihold", cg.uint8), + automation.ApplyField(CONF_IHOLDDELAY, "set_iholddelay", cg.uint8), + automation.ApplyField(CONF_TPOWERDOWN, "set_tpowerdown", cg.uint8), + automation.ApplyField( + CONF_STANDSTILL_MODE, "set_standstill_mode", StandstillMode + ), + ) diff --git a/esphome/components/tmc22xx/tmc22xx.cpp b/esphome/components/tmc22xx/tmc22xx.cpp new file mode 100644 index 0000000000..1628572d40 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.cpp @@ -0,0 +1,372 @@ +#include "tmc22xx.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::tmc22xx { + +ESPHOME_LOG_TAG(TAG, "tmc22xx"); + +static constexpr uint8_t SYNC_BYTE = 0x05; +static constexpr uint8_t MASTER_ADDRESS = 0xFF; +static constexpr uint8_t WRITE_BIT = 0x80; +static constexpr size_t READ_REQUEST_SIZE = 4; +static constexpr size_t DATAGRAM_SIZE = 8; +static constexpr uint8_t DEFAULT_TOFF = 3; +static constexpr float SENSE_RESISTOR_OFFSET = 0.02f; // Ohm, added to RSENSE in the datasheet current formula +static constexpr float INTERNAL_RSENSE_OHM = 0.17f; // Equivalent sense resistance when using internal sensing +// INDEX pulses are counted once per loop, so near the target the speed is limited to cover the remaining +// distance in no less than this time. A slow loop pass then cannot run far past the target. +static constexpr float APPROACH_TIME_S = 0.02f; +// Skip VACTUAL writes that change the speed by less than 1/16, each write blocks the loop for the UART transfer +static constexpr int32_t VACTUAL_CHANGE_DIVISOR = 16; + +// CRC-8 with polynomial 0x07 over the datagram bits in the order they go out on the wire, LSB first +static uint8_t datagram_crc(const uint8_t *data, size_t len) { + uint8_t crc = 0; + for (size_t i = 0; i < len; i++) { + const uint8_t reflected = reverse_bits(data[i]); + crc = crc8(&reflected, 1, crc, 0x07, true); + } + return crc; +} + +void IRAM_ATTR IndexPulseStore::gpio_intr(IndexPulseStore *arg) { arg->pulses = arg->pulses + arg->direction; } + +void TMC22XXStepper::setup() { + if (this->enn_pin_ != nullptr) { + this->enn_pin_->setup(); + this->enn_pin_->digital_write(true); + } + + auto version = this->read_field(VERSION); + if (!version.has_value()) { + ESP_LOGE(TAG, "No response from driver at address %u", this->address_); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->version_ = *version; + if (this->version_ != this->expected_version()) { + ESP_LOGW(TAG, "Unexpected IC version 0x%02X", this->version_); + } + + // Bring write-only registers in line with their cached values and clear the reset flag + this->write_register(REG_IHOLD_IRUN, this->ihold_irun_); + this->write_register(REG_TPOWERDOWN, this->tpowerdown_); + this->write_register(REG_TPWMTHRS, this->tpwmthrs_); + this->write_register(REG_VACTUAL, 0); + this->write_register(REG_GSTAT, 0x07); + + this->write_field(PDN_DISABLE, true); + this->write_field(MSTEP_REG_SELECT, true); + this->write_field(INTERNAL_RSENSE, std::isnan(this->rsense_)); + this->write_field(I_SCALE_ANALOG, this->analog_current_scale_); + if (this->vsense_.has_value()) + this->write_field(VSENSE, *this->vsense_); + if (this->ottrim_.has_value()) + this->write_field(OTTRIM, *this->ottrim_); + this->vsense_active_ = this->read_field(VSENSE).value_or(0); + + // TOFF=0 disables the outputs, keep the current value so it can be restored on enable + auto toff = this->read_field(TOFF); + if (toff.has_value() && *toff != 0) + this->toff_ = *toff; + + this->set_microsteps(this->initial_microsteps_); + if (!std::isnan(this->initial_run_current_)) + this->set_run_current(this->initial_run_current_); + if (!std::isnan(this->initial_hold_current_)) + this->set_hold_current(this->initial_hold_current_); + + if (this->index_pin_ != nullptr) { + // INDEX toggles on every step of the internal pulse generator that VACTUAL drives + this->write_field(INDEX_OTPW, false); + this->write_field(INDEX_STEP, true); + this->index_pin_->setup(); + this->index_pin_->attach_interrupt(IndexPulseStore::gpio_intr, &this->index_store_, gpio::INTERRUPT_ANY_EDGE); + } else { + // Step on both edges, so every step pin toggle is one step + this->write_field(DEDGE, true); + this->write_field(MULTISTEP_FILT, false); + this->step_pin_->setup(); + this->step_pin_->digital_write(false); + this->dir_pin_->setup(); + this->dir_pin_->digital_write(false); + } + + this->set_enabled(true); +} + +void TMC22XXStepper::dump_config() { + ESP_LOGCONFIG(TAG, + " Address: %u\n" + " IC version: 0x%02X", + this->address_, this->version_); + // Register reads on a failed driver would only wait for the UART timeout + if (!this->is_failed()) { + uint8_t irun = this->read_field(IRUN).value_or(0); + uint8_t ihold = this->read_field(IHOLD).value_or(0); + ESP_LOGCONFIG(TAG, " Microsteps: %u", this->get_microsteps()); + if (this->analog_current_scale_) { + ESP_LOGCONFIG(TAG, + " Run current scale: %u\n" + " Hold current scale: %u\n" + " Current scaled by VREF", + irun, ihold); + } else { + ESP_LOGCONFIG(TAG, + " Run current: %.2f A\n" + " Hold current: %.2f A\n" + " Max current: %.2f A", + this->scale_to_current(irun), this->scale_to_current(ihold), this->scale_to_current(31)); + } + } + LOG_PIN(" ENN Pin: ", this->enn_pin_); + LOG_PIN(" STEP Pin: ", this->step_pin_); + LOG_PIN(" DIR Pin: ", this->dir_pin_); + LOG_PIN(" INDEX Pin: ", this->index_pin_); + LOG_STEPPER(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void TMC22XXStepper::loop() { + if (this->index_pin_ != nullptr) + this->add_index_pulses_(); + if (this->has_reached_target()) { + this->high_freq_.stop(); + } else { + this->high_freq_.start(); + if (!this->enabled_) + this->set_enabled(true); + } + + if (this->index_pin_ != nullptr) { + this->loop_serial_(); + } else { + this->loop_step_dir_(); + } +} + +void TMC22XXStepper::add_index_pulses_() { + InterruptLock lock; + this->current_position += this->index_store_.pulses; + this->index_store_.pulses = 0; +} + +void TMC22XXStepper::loop_serial_() { + int32_t remaining = this->target_position - this->current_position; + int8_t direction = (remaining > 0) - (remaining < 0); + // Ran past the target, restart the ramp from standstill in the other direction + if (direction != 0 && direction == -this->direction_) + this->current_speed_ = 0.0f; + this->calculate_speed_(micros()); + this->current_speed_ = std::min(this->current_speed_, static_cast(std::abs(remaining)) / APPROACH_TIME_S); + + int32_t vactual = direction * this->speed_to_vactual_(this->current_speed_); + int32_t last = this->get_vactual_(); + if (vactual == last) + return; + if (vactual != 0 && (vactual > 0) == (last > 0) && std::abs(vactual - last) < std::abs(last) / VACTUAL_CHANGE_DIVISOR) + return; + // Pulses that arrive while VACTUAL changes direction are counted in the new direction + this->index_store_.direction = direction; + this->direction_ = direction; + this->write_field(VACTUAL, static_cast(vactual)); +} + +void TMC22XXStepper::loop_step_dir_() { + int32_t direction = this->should_step_(); + if (direction == 0) + return; + if (direction != this->direction_) { + this->dir_pin_->digital_write(direction > 0); + this->direction_ = direction; + delayMicroseconds(1); + } + this->step_state_ = !this->step_state_; + this->step_pin_->digital_write(this->step_state_); +} + +void TMC22XXStepper::on_shutdown() { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); +} + +void TMC22XXStepper::set_enabled(bool enabled) { + if (!enabled) { + if (this->get_vactual_() != 0) + this->write_field(VACTUAL, 0); + if (this->index_pin_ != nullptr) { + // Count the pulses up to the stop, so the position is current before the target is set + this->add_index_pulses_(); + this->index_store_.direction = 0; + this->direction_ = 0; + } + // Stop at the current position, so the motor does not run off again when re-enabled + this->target_position = this->current_position; + } + if (this->enn_pin_ != nullptr) { + this->enn_pin_->digital_write(!enabled); + } else { + this->write_field(TOFF, enabled ? this->toff_ : 0); + } + this->enabled_ = enabled; +} + +void TMC22XXStepper::set_microsteps(uint16_t microsteps) { + for (uint8_t mres = 0; mres <= 8; mres++) { + if ((256u >> mres) == microsteps) { + this->write_field(MRES, mres); + return; + } + } + ESP_LOGW(TAG, "Invalid microsteps %u", microsteps); +} + +uint16_t TMC22XXStepper::get_microsteps() { + auto mres = this->read_field(MRES); + if (!mres.has_value() || *mres > 8) + return 0; + return 256u >> *mres; +} + +void TMC22XXStepper::set_run_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IRUN, *scale); +} + +void TMC22XXStepper::set_hold_current(float current) { + if (auto scale = this->current_to_scale_(current)) + this->write_field(IHOLD, *scale); +} + +float TMC22XXStepper::full_scale_current_() { + return (this->vsense_active_ ? 0.180f : 0.325f) / + ((std::isnan(this->rsense_) ? INTERNAL_RSENSE_OHM : this->rsense_) + SENSE_RESISTOR_OFFSET); +} + +float TMC22XXStepper::scale_to_current(uint8_t scale) { + return (std::min(scale, 31) + 1) / 32.0f * this->full_scale_current_() / std::numbers::sqrt2_v; +} + +optional TMC22XXStepper::current_to_scale_(float current) { + if (this->analog_current_scale_) { + ESP_LOGW(TAG, "Current in A is not supported with analog current scale, use irun/ihold"); + return {}; + } + float scale = 32.0f * std::numbers::sqrt2_v * current / this->full_scale_current_() - 1.0f; + if (scale > 31.0f) { + ESP_LOGW(TAG, "%.2f A is above the %.2f A limit, using the limit", current, this->scale_to_current(31)); + return 31; + } + return static_cast(std::max(0.0f, std::round(scale))); +} + +int32_t TMC22XXStepper::speed_to_vactual_(float speed) const { + // VACTUAL is in microsteps per 2^24 clock cycles + return static_cast(std::lround(speed * 16777216.0f / this->clock_frequency_)); +} + +uint32_t *TMC22XXStepper::shadow_register(uint8_t reg) { + switch (reg) { + case REG_IHOLD_IRUN: + return &this->ihold_irun_; + case REG_TPOWERDOWN: + return &this->tpowerdown_; + case REG_TPWMTHRS: + return &this->tpwmthrs_; + case REG_VACTUAL: + return &this->vactual_reg_; + default: + return nullptr; + } +} + +bool TMC22XXStepper::write_register(uint8_t reg, uint32_t value) { + uint8_t datagram[DATAGRAM_SIZE] = {SYNC_BYTE, + this->address_, + static_cast(reg | WRITE_BIT), + static_cast(value >> 24), + static_cast(value >> 16), + static_cast(value >> 8), + static_cast(value), + 0}; + datagram[7] = datagram_crc(datagram, 7); + + while (this->available()) + this->read(); + this->write_array(datagram, DATAGRAM_SIZE); + this->flush(); + // On the single wire bus every byte sent is also received, drop that echo + uint8_t echo[DATAGRAM_SIZE]; + if (!this->read_array(echo, DATAGRAM_SIZE) || memcmp(echo, datagram, DATAGRAM_SIZE) != 0) { + ESP_LOGV(TAG, "No echo writing register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + this->status_clear_warning(); + + if (uint32_t *shadow = this->shadow_register(reg)) + *shadow = value; + return true; +} + +optional TMC22XXStepper::read_register(uint8_t reg) { + if (uint32_t *shadow = this->shadow_register(reg)) + return *shadow; + + uint8_t request[READ_REQUEST_SIZE] = {SYNC_BYTE, this->address_, reg, 0}; + request[3] = datagram_crc(request, 3); + + while (this->available()) + this->read(); + this->write_array(request, READ_REQUEST_SIZE); + this->flush(); + uint8_t reply[DATAGRAM_SIZE]; + if (!this->read_array(reply, READ_REQUEST_SIZE) || !this->read_array(reply, DATAGRAM_SIZE)) { + ESP_LOGV(TAG, "No reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + if (reply[0] != SYNC_BYTE || reply[1] != MASTER_ADDRESS || reply[2] != reg || reply[7] != datagram_crc(reply, 7)) { + ESP_LOGV(TAG, "Invalid reply reading register 0x%02X", reg); + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return {}; + } + this->status_clear_warning(); + return encode_uint32(reply[3], reply[4], reply[5], reply[6]); +} + +bool TMC22XXStepper::write_field(const RegisterField &field, uint32_t value) { + // Skip the UART timeout when the driver never answered during setup + if (this->is_failed()) + return false; + auto data = this->read_register(field.reg); + if (!data.has_value()) + return false; + uint32_t mask = field.mask(); + return this->write_register(field.reg, (*data & ~mask) | ((value << field.shift) & mask)); +} + +optional TMC22XXStepper::read_field(const RegisterField &field) { + auto data = this->read_register(field.reg); + if (!data.has_value()) + return {}; + return extract_field(*data, field); +} + +uint32_t TMC22XXStepper::extract_field(uint32_t data, const RegisterField &field) { + uint32_t value = (data & field.mask()) >> field.shift; + if (field.is_signed && field.width < 32) { + uint32_t sign_bit = 1u << (field.width - 1); + value = (value ^ sign_bit) - sign_bit; + } + return value; +} + +} // namespace esphome::tmc22xx diff --git a/esphome/components/tmc22xx/tmc22xx.h b/esphome/components/tmc22xx/tmc22xx.h new file mode 100644 index 0000000000..8b0c92d352 --- /dev/null +++ b/esphome/components/tmc22xx/tmc22xx.h @@ -0,0 +1,167 @@ +#pragma once + +#include + +#include "esphome/components/stepper/stepper.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" + +namespace esphome::tmc22xx { + +/// A bit field inside one register. +struct RegisterField { + uint8_t reg; + uint8_t shift; + uint8_t width; + bool is_signed; + + constexpr uint32_t mask() const { return ((width >= 32) ? 0xFFFFFFFFu : ((1u << width) - 1u)) << shift; } +}; + +// Registers shared by the TMC22xx family (TMC2202, TMC2208, TMC2209, TMC2224, TMC2225, TMC2226) +static constexpr uint8_t REG_GCONF = 0x00; +static constexpr uint8_t REG_GSTAT = 0x01; +static constexpr uint8_t REG_IOIN = 0x06; +static constexpr uint8_t REG_FACTORY_CONF = 0x07; +static constexpr uint8_t REG_IHOLD_IRUN = 0x10; +static constexpr uint8_t REG_TPOWERDOWN = 0x11; +static constexpr uint8_t REG_TPWMTHRS = 0x13; +static constexpr uint8_t REG_VACTUAL = 0x22; +static constexpr uint8_t REG_CHOPCONF = 0x6C; +static constexpr uint8_t REG_PWMCONF = 0x70; + +static constexpr RegisterField I_SCALE_ANALOG{REG_GCONF, 0, 1, false}; +static constexpr RegisterField INTERNAL_RSENSE{REG_GCONF, 1, 1, false}; +static constexpr RegisterField EN_SPREADCYCLE{REG_GCONF, 2, 1, false}; +static constexpr RegisterField SHAFT{REG_GCONF, 3, 1, false}; +static constexpr RegisterField INDEX_OTPW{REG_GCONF, 4, 1, false}; +static constexpr RegisterField INDEX_STEP{REG_GCONF, 5, 1, false}; +static constexpr RegisterField PDN_DISABLE{REG_GCONF, 6, 1, false}; +static constexpr RegisterField MSTEP_REG_SELECT{REG_GCONF, 7, 1, false}; +static constexpr RegisterField MULTISTEP_FILT{REG_GCONF, 8, 1, false}; +static constexpr RegisterField VERSION{REG_IOIN, 24, 8, false}; +static constexpr RegisterField OTTRIM{REG_FACTORY_CONF, 8, 2, false}; +static constexpr RegisterField IHOLD{REG_IHOLD_IRUN, 0, 5, false}; +static constexpr RegisterField IRUN{REG_IHOLD_IRUN, 8, 5, false}; +static constexpr RegisterField IHOLDDELAY{REG_IHOLD_IRUN, 16, 4, false}; +static constexpr RegisterField TPOWERDOWN{REG_TPOWERDOWN, 0, 8, false}; +static constexpr RegisterField TPWMTHRS{REG_TPWMTHRS, 0, 20, false}; +static constexpr RegisterField VACTUAL{REG_VACTUAL, 0, 24, true}; +static constexpr RegisterField TOFF{REG_CHOPCONF, 0, 4, false}; +static constexpr RegisterField VSENSE{REG_CHOPCONF, 17, 1, false}; +static constexpr RegisterField MRES{REG_CHOPCONF, 24, 4, false}; +static constexpr RegisterField INTPOL{REG_CHOPCONF, 28, 1, false}; +static constexpr RegisterField DEDGE{REG_CHOPCONF, 29, 1, false}; +static constexpr RegisterField FREEWHEEL{REG_PWMCONF, 20, 2, false}; + +enum StandstillMode : uint8_t { + STANDSTILL_MODE_NORMAL = 0, + STANDSTILL_MODE_FREEWHEELING = 1, + STANDSTILL_MODE_COIL_SHORT_LS = 2, + STANDSTILL_MODE_COIL_SHORT_HS = 3, +}; + +/// Counts the step pulses the driver reports on INDEX while it runs from VACTUAL. +struct IndexPulseStore { + volatile int32_t pulses{0}; + volatile int8_t direction{0}; + static void gpio_intr(IndexPulseStore *arg); +}; + +/// Base class for Trinamic stepper drivers that are configured over the single wire UART interface. +class TMC22XXStepper : public stepper::Stepper, public Component, public uart::UARTDevice { + public: + 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_address(uint8_t address) { this->address_ = address; } + void set_enn_pin(GPIOPin *pin) { this->enn_pin_ = pin; } + void set_step_pin(GPIOPin *pin) { this->step_pin_ = pin; } + void set_dir_pin(GPIOPin *pin) { this->dir_pin_ = pin; } + void set_index_pin(InternalGPIOPin *pin) { this->index_pin_ = pin; } + void set_clock_frequency(uint32_t frequency) { this->clock_frequency_ = frequency; } + void set_rsense(float rsense) { this->rsense_ = rsense; } + void set_vsense(bool vsense) { this->vsense_ = vsense; } + void set_ottrim(uint8_t ottrim) { this->ottrim_ = ottrim; } + void set_analog_current_scale(bool enable) { this->analog_current_scale_ = enable; } + void set_initial_run_current(float current) { this->initial_run_current_ = current; } + void set_initial_hold_current(float current) { this->initial_hold_current_ = current; } + void set_initial_microsteps(uint16_t microsteps) { this->initial_microsteps_ = microsteps; } + + /// Enable or disable the motor outputs, using the ENN pin when configured and TOFF otherwise. + void set_enabled(bool enabled); + bool is_enabled() const { return this->enabled_; } + + void set_microsteps(uint16_t microsteps); + uint16_t get_microsteps(); + void set_interpolation(bool enable) { this->write_field(INTPOL, enable); } + void set_spreadcycle(bool enable) { this->write_field(EN_SPREADCYCLE, enable); } + void set_inverse_direction(bool inverse) { this->write_field(SHAFT, inverse); } + void set_tpwm_threshold(uint32_t threshold) { this->write_field(TPWMTHRS, threshold); } + + /// Set the motor run or hold current in A RMS. + /// Not supported with analog current scale, use set_irun() and set_ihold() instead. + void set_run_current(float current); + void set_hold_current(float current); + void set_irun(uint8_t irun) { this->write_field(IRUN, irun); } + void set_ihold(uint8_t ihold) { this->write_field(IHOLD, ihold); } + void set_iholddelay(uint8_t delay) { this->write_field(IHOLDDELAY, delay); } + void set_tpowerdown(uint8_t tpowerdown) { this->write_field(TPOWERDOWN, tpowerdown); } + void set_standstill_mode(StandstillMode mode) { this->write_field(FREEWHEEL, mode); } + /// Convert a current scale (0-31) to A RMS, without analog current scale. + float scale_to_current(uint8_t scale); + + bool write_register(uint8_t reg, uint32_t value); + optional read_register(uint8_t reg); + bool write_field(const RegisterField &field, uint32_t value); + optional read_field(const RegisterField &field); + static uint32_t extract_field(uint32_t data, const RegisterField &field); + + protected: + /// IC version reported in IOIN by the supported chip. + virtual uint8_t expected_version() const = 0; + /// Return the cached value of a write-only register, or nullptr when the register is readable. + virtual uint32_t *shadow_register(uint8_t reg); + optional current_to_scale_(float current); + float full_scale_current_(); + int32_t get_vactual_() const { return static_cast(extract_field(this->vactual_reg_, VACTUAL)); } + int32_t speed_to_vactual_(float speed) const; + void add_index_pulses_(); + void loop_serial_(); + void loop_step_dir_(); + + GPIOPin *enn_pin_{nullptr}; + GPIOPin *step_pin_{nullptr}; + GPIOPin *dir_pin_{nullptr}; + InternalGPIOPin *index_pin_{nullptr}; + uint32_t clock_frequency_{12000000}; + // Write-only registers shared by all family members, with their power-on values + uint32_t ihold_irun_{0x00071703}; + uint32_t tpowerdown_{0x14}; + uint32_t tpwmthrs_{0}; + uint32_t vactual_reg_{0}; + // NaN while not configured + float rsense_{NAN}; + float initial_run_current_{NAN}; + float initial_hold_current_{NAN}; + IndexPulseStore index_store_{}; + uint16_t initial_microsteps_{16}; + optional vsense_{}; + optional ottrim_{}; + uint8_t address_{0}; + uint8_t toff_{3}; + uint8_t version_{0}; + int8_t direction_{0}; + bool analog_current_scale_{false}; + bool enabled_{false}; + bool vsense_active_{false}; + bool step_state_{false}; + HighFrequencyLoopRequester high_freq_; +}; + +} // namespace esphome::tmc22xx diff --git a/esphome/components/tmp102/__init__.py b/esphome/components/tmp102/__init__.py index 3e32a230f2..077dd03ccc 100644 --- a/esphome/components/tmp102/__init__.py +++ b/esphome/components/tmp102/__init__.py @@ -1,9 +1,104 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. +"""Shared TMP102 types and configuration validation.""" -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf +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 CONF_PLATFORM +import esphome.final_validate as fv +from esphome.types import ConfigType -""" +CONF_TMP102_ID = "tmp102_id" + +DOMAIN = "tmp102" + +tmp102_ns = cg.esphome_ns.namespace("tmp102") +TMP102Component = tmp102_ns.class_( + "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor +) + +CONF_EXTENDED_MODE = "extended_mode" +CONF_ONE_SHOT_MODE = "one_shot_mode" +CONF_TEMPERATURE_HIGH = "temperature_high" +CONF_TEMPERATURE_LOW = "temperature_low" +CONF_ALERT_POLARITY = "alert_polarity" +CONF_THERMOSTAT_MODE = "thermostat_mode" +CONF_FAULT_QUEUE = "fault_queue" + +CONVERSION_RATES = {"0.25Hz": 0, "1Hz": 1, "4Hz": 2, "8Hz": 3} +THERMOSTAT_MODES = {"comparator": 0, "interrupt": 1} +ALERT_POLARITIES = {"active_low": 0, "active_high": 1} +FAULT_QUEUE_BITS = {1: 0, 2: 1, 4: 2, 6: 3} + +TMP102_CONVERSION_FACTOR = 0.0625 +TMP102_DEFAULT_THIGH = 80.0 +TMP102_DEFAULT_TLOW = 75.0 + + +def encode_temperature(temperature: float, extended: bool) -> int: + """Encode a temperature as a TMP102 limit-register word.""" + steps = round(temperature / TMP102_CONVERSION_FACTOR) + return (steps << (3 if extended else 4)) & 0xFFFF + + +def build_configuration(config: ConfigType) -> int: + """Build the writable TMP102 configuration-register bits.""" + value = CONVERSION_RATES[config.get(CONF_CONVERSION_RATE, "4Hz")] << 6 + value |= FAULT_QUEUE_BITS[config.get(CONF_FAULT_QUEUE, 1)] << 11 + value |= ALERT_POLARITIES[config.get(CONF_ALERT_POLARITY, "active_low")] << 10 + value |= THERMOSTAT_MODES[config.get(CONF_THERMOSTAT_MODE, "comparator")] << 9 + if config.get(CONF_ONE_SHOT_MODE, False): + value |= 1 << 8 + if config.get(CONF_EXTENDED_MODE, False): + value |= 1 << 4 + return value + + +def validate_tmp102_thresholds(config: ConfigType) -> ConfigType: + extended = config.get(CONF_EXTENDED_MODE, False) + t_min = -55.0 + t_max = 150.0 if extended else 127.9375 + high = config.get(CONF_TEMPERATURE_HIGH) + low = config.get(CONF_TEMPERATURE_LOW) + for key, value in ((CONF_TEMPERATURE_HIGH, high), (CONF_TEMPERATURE_LOW, low)): + if value is not None and not t_min <= value <= t_max: + raise cv.Invalid( + f"{key} ({value}°C) out of range for " + f"{'extended' if extended else 'normal'} mode " + f"({t_min}°C to {t_max}°C)", + [key], + ) + + if high is not None and low is not None and low > high: + raise cv.Invalid( + f"low limit ({low}°C) must be <= high limit ({high}°C)", + [CONF_TEMPERATURE_LOW], + ) + if high is not None and low is None and high < TMP102_DEFAULT_TLOW: + raise cv.Invalid( + f"high limit ({high}°C) is below the chip power-up TLOW default " + f"({TMP102_DEFAULT_TLOW}°C). Set temperature_low explicitly.", + [CONF_TEMPERATURE_HIGH], + ) + if low is not None and high is None and low > TMP102_DEFAULT_THIGH: + raise cv.Invalid( + f"low limit ({low}°C) is above the chip power-up THIGH default " + f"({TMP102_DEFAULT_THIGH}°C). Set temperature_high explicitly.", + [CONF_TEMPERATURE_LOW], + ) + return config + + +def validate_unique_child(config: ConfigType, platform: str) -> ConfigType: + entries = fv.full_config.get().get(platform, []) + matches = [ + entry + for entry in entries + if entry.get(CONF_PLATFORM) == DOMAIN + and entry.get(CONF_TMP102_ID) == config[CONF_TMP102_ID] + ] + if len(matches) > 1: + raise cv.Invalid( + f"Only one TMP102 {platform} may reference each temperature sensor" + ) + return config diff --git a/esphome/components/tmp102/binary_sensor.py b/esphome/components/tmp102/binary_sensor.py new file mode 100644 index 0000000000..eb23206b98 --- /dev/null +++ b/esphome/components/tmp102/binary_sensor.py @@ -0,0 +1,28 @@ +"""TMP102 comparator alert entity.""" + +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import CONF_TMP102_ID, TMP102Component, validate_unique_child + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_TMP102_ID): cv.use_id(TMP102Component), + } +) + + +def _final_validate(config: ConfigType) -> ConfigType: + return validate_unique_child(config, "binary_sensor") + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_TMP102_BINARY_SENSOR") + parent = await cg.get_variable(config[CONF_TMP102_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(parent.set_alert_binary_sensor(var)) diff --git a/esphome/components/tmp102/sensor.py b/esphome/components/tmp102/sensor.py index eba0d3c026..fbfed7444d 100644 --- a/esphome/components/tmp102/sensor.py +++ b/esphome/components/tmp102/sensor.py @@ -1,15 +1,8 @@ -""" -The TMP102 is a two-wire, serial output temperature -sensor available in a tiny SOT563 package. Requiring -no external components, the TMP102 is capable of -reading temperatures to a resolution of 0.0625°C. - -https://www.sparkfun.com/datasheets/Sensors/Temperature/tmp102.pdf - -""" +"""TMP102 temperature sensor and optional hardware configuration.""" 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, @@ -18,15 +11,42 @@ from esphome.const import ( ) from esphome.types import ConfigType +from . import ( + ALERT_POLARITIES, + CONF_ALERT_POLARITY, + CONF_EXTENDED_MODE, + CONF_FAULT_QUEUE, + CONF_ONE_SHOT_MODE, + CONF_TEMPERATURE_HIGH, + CONF_TEMPERATURE_LOW, + CONF_THERMOSTAT_MODE, + CONVERSION_RATES, + FAULT_QUEUE_BITS, + THERMOSTAT_MODES, + TMP102_DEFAULT_THIGH, + TMP102_DEFAULT_TLOW, + TMP102Component, + build_configuration, + encode_temperature, + validate_tmp102_thresholds, +) + CODEOWNERS = ["@timsavage"] DEPENDENCIES = ["i2c"] -tmp102_ns = cg.esphome_ns.namespace("tmp102") -TMP102Component = tmp102_ns.class_( - "TMP102Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor -) +# No defaults here: absence of all advanced options preserves upstream's read-only behavior and firmware size. +_ADVANCED_SCHEMA = { + cv.Optional(CONF_EXTENDED_MODE): cv.boolean, + cv.Optional(CONF_CONVERSION_RATE): cv.enum(CONVERSION_RATES), + cv.Optional(CONF_ONE_SHOT_MODE): cv.boolean, + cv.Optional(CONF_ALERT_POLARITY): cv.enum(ALERT_POLARITIES), + cv.Optional(CONF_THERMOSTAT_MODE): cv.enum(THERMOSTAT_MODES), + cv.Optional(CONF_FAULT_QUEUE): cv.enum(FAULT_QUEUE_BITS, int=True), + cv.Optional(CONF_TEMPERATURE_HIGH): cv.temperature, + cv.Optional(CONF_TEMPERATURE_LOW): cv.temperature, +} -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( sensor.sensor_schema( TMP102Component, unit_of_measurement=UNIT_CELSIUS, @@ -36,6 +56,8 @@ CONFIG_SCHEMA = ( ) .extend(cv.polling_component_schema("60s")) .extend(i2c.i2c_device_schema(0x48)) + .extend(_ADVANCED_SCHEMA), + validate_tmp102_thresholds, ) @@ -43,3 +65,18 @@ async def to_code(config: ConfigType) -> None: var = await sensor.new_sensor(config) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) + + if not any(key in config for key in _ADVANCED_SCHEMA): + return + + extended = config.get(CONF_EXTENDED_MODE, False) + high = config.get(CONF_TEMPERATURE_HIGH, TMP102_DEFAULT_THIGH) + low = config.get(CONF_TEMPERATURE_LOW, TMP102_DEFAULT_TLOW) + cg.add_define("USE_TMP102_CONFIGURE") + cg.add( + var.set_configuration( + build_configuration(config), + encode_temperature(high, extended), + encode_temperature(low, extended), + ) + ) diff --git a/esphome/components/tmp102/tmp102.cpp b/esphome/components/tmp102/tmp102.cpp index cb2462858b..09b29dd7ef 100644 --- a/esphome/components/tmp102/tmp102.cpp +++ b/esphome/components/tmp102/tmp102.cpp @@ -4,15 +4,40 @@ namespace esphome::tmp102 { -static const char *const TAG = "tmp102"; +ESPHOME_LOG_TAG(TAG, "tmp102"); -static const uint8_t TMP102_ADDRESS = 0x48; -static const uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; -static const uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; -static const uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; -static const uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; +static constexpr uint32_t READ_TEMP_TIMEOUT_ID = 0; -static const float TMP102_CONVERSION_FACTOR = 0.0625; +static constexpr uint8_t TMP102_REGISTER_TEMPERATURE = 0x00; +static constexpr float TMP102_CONVERSION_FACTOR = 0.0625f; +#if defined(USE_TMP102_CONFIGURE) || defined(USE_TMP102_BINARY_SENSOR) +static constexpr uint8_t TMP102_REGISTER_CONFIGURATION = 0x01; +static constexpr uint16_t TMP102_ALERT_BIT = 0x0020; +static constexpr uint16_t TMP102_ALERT_POLARITY_BIT = 0x0400; +#endif +#ifdef USE_TMP102_CONFIGURE +static constexpr uint8_t TMP102_REGISTER_LOW_LIMIT = 0x02; +static constexpr uint8_t TMP102_REGISTER_HIGH_LIMIT = 0x03; + +static constexpr uint16_t TMP102_CONFIGURATION_MASK = 0x1FD0; +static constexpr uint16_t TMP102_RESET_CONFIGURATION = 0x60A0; +static constexpr uint16_t TMP102_ONE_SHOT_BIT = 0x8000; +static constexpr uint16_t TMP102_SHUTDOWN_BIT = 0x0100; +static constexpr uint16_t TMP102_THERMOSTAT_MODE_BIT = 0x0200; +static constexpr uint16_t TMP102_EXTENDED_MODE_BIT = 0x0010; +static constexpr uint32_t TMP102_ONESHOT_DELAY_MS = 40; + +static float decode_temperature(uint16_t raw, bool extended) { + return (static_cast(raw) >> (extended ? 3 : 4)) * TMP102_CONVERSION_FACTOR; +} + +void TMP102Component::setup() { + if (!this->configure_) + return; + if (!this->write_configuration_()) + this->mark_failed(); +} +#endif void TMP102Component::dump_config() { ESP_LOGCONFIG(TAG, "TMP102:"); @@ -22,17 +47,55 @@ void TMP102Component::dump_config() { } LOG_UPDATE_INTERVAL(this); LOG_SENSOR(" ", "Temperature", this); +#ifdef USE_TMP102_BINARY_SENSOR + LOG_BINARY_SENSOR(" ", "Alert", this->alert_binary_sensor_); +#endif +#ifdef USE_TMP102_CONFIGURE + if (!this->configure_) + return; + const uint8_t rate = (this->config_ >> 6) & 0x03; + const LogString *rate_str = rate == 0 ? LOG_STR("0.25Hz") + : rate == 1 ? LOG_STR("1Hz") + : rate == 2 ? LOG_STR("4Hz") + : LOG_STR("8Hz"); + const uint8_t fault_bits = (this->config_ >> 11) & 0x03; + const bool extended = this->config_ & TMP102_EXTENDED_MODE_BIT; + ESP_LOGCONFIG( + TAG, + " Extended Mode: %s\n" + " One-Shot Mode: %s\n" + " Conversion Rate: %s\n" + " Thermostat Mode: %s\n" + " Alert Polarity: %s\n" + " Fault Queue: %u\n" + " Temperature High: %.4f°C\n" + " Temperature Low: %.4f°C", + YESNO(extended), YESNO(this->config_ & TMP102_SHUTDOWN_BIT), LOG_STR_ARG(rate_str), + this->config_ & TMP102_THERMOSTAT_MODE_BIT ? LOG_STR_LITERAL("Interrupt") : LOG_STR_LITERAL("Comparator"), + this->config_ & TMP102_ALERT_POLARITY_BIT ? LOG_STR_LITERAL("Active High") : LOG_STR_LITERAL("Active Low"), + fault_bits == 3 ? 6 : 1U << fault_bits, decode_temperature(this->high_limit_, extended), + decode_temperature(this->low_limit_, extended)); + if (this->config_ & TMP102_THERMOSTAT_MODE_BIT) { + ESP_LOGW(TAG, "Register reads clear the interrupt-mode ALERT pin"); + } +#endif } void TMP102Component::update() { - if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { - this->status_set_warning(); +#ifdef USE_TMP102_CONFIGURE + if (this->configure_) { + this->update_configured_(); return; } - this->set_timeout("read_temp", 50, [this]() { +#endif + if (this->write(&TMP102_REGISTER_TEMPERATURE, 1) != i2c::ERROR_OK) { + this->set_warning_and_invalidate_alert_(); + return; + } + this->set_timeout(READ_TEMP_TIMEOUT_ID, 50, [this]() { int16_t raw_temperature; if (this->read(reinterpret_cast(&raw_temperature), 2) != i2c::ERROR_OK) { - this->status_set_warning(); + this->set_warning_and_invalidate_alert_(); return; } raw_temperature = i2c::i2ctohs(raw_temperature); @@ -41,8 +104,107 @@ void TMP102Component::update() { ESP_LOGD(TAG, "Got Temperature=%.1f°C", temperature); this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif this->status_clear_warning(); }); } +#ifdef USE_TMP102_CONFIGURE +void TMP102Component::update_configured_() { + if (!this->check_configuration_()) + return; + if (this->config_ & TMP102_SHUTDOWN_BIT) { + if (!this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_ | TMP102_ONE_SHOT_BIT)) { + this->set_warning_and_invalidate_alert_(); + return; + } + this->set_timeout(READ_TEMP_TIMEOUT_ID, TMP102_ONESHOT_DELAY_MS, [this]() { this->read_temperature_(); }); + } else { + this->read_temperature_(); + } +} + +bool TMP102Component::write_register_(uint8_t reg, uint16_t value) { + uint8_t frame[3] = {reg, static_cast(value >> 8), static_cast(value)}; + if (this->write(frame, 3) == i2c::ERROR_OK) + return true; + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return false; +} + +bool TMP102Component::write_configuration_() { + return this->write_register_(TMP102_REGISTER_CONFIGURATION, this->config_) && + this->write_register_(TMP102_REGISTER_HIGH_LIMIT, this->high_limit_) && + this->write_register_(TMP102_REGISTER_LOW_LIMIT, this->low_limit_); +} + +bool TMP102Component::check_configuration_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->set_warning_and_invalidate_alert_(); + return false; + } + bool matches = (config & TMP102_CONFIGURATION_MASK) == (this->config_ & TMP102_CONFIGURATION_MASK); + // A chip reset is invisible in the configuration register when it matches the reset default; check the limits. + if (matches && + (this->config_ & TMP102_CONFIGURATION_MASK) == (TMP102_RESET_CONFIGURATION & TMP102_CONFIGURATION_MASK)) { + uint16_t high, low; + if (!this->read_byte_16(TMP102_REGISTER_HIGH_LIMIT, &high) || + !this->read_byte_16(TMP102_REGISTER_LOW_LIMIT, &low)) { + this->set_warning_and_invalidate_alert_(); + return false; + } + matches = high == this->high_limit_ && low == this->low_limit_; + } + if (matches) + return true; + + ESP_LOGW(TAG, "Configuration changed; restoring configured settings and thresholds"); + this->set_warning_and_invalidate_alert_(); + if (!this->write_configuration_()) { + ESP_LOGW(TAG, "Failed to restore configuration and thresholds"); + } + return false; +} + +void TMP102Component::read_temperature_() { + uint16_t raw; + if (!this->read_byte_16(TMP102_REGISTER_TEMPERATURE, &raw)) { + this->set_warning_and_invalidate_alert_(); + return; + } + const float temperature = decode_temperature(raw, raw & 0x0001); + ESP_LOGD(TAG, "Got Temperature=%.2f°C", temperature); + this->publish_state(temperature); +#ifdef USE_TMP102_BINARY_SENSOR + if (this->alert_binary_sensor_ != nullptr && !this->read_alert_state_()) + return; +#endif + this->status_clear_warning(); +} +#endif + +#ifdef USE_TMP102_BINARY_SENSOR +void TMP102Component::invalidate_alert_() { + if (this->alert_binary_sensor_ != nullptr) + this->alert_binary_sensor_->invalidate_state(); +} + +bool TMP102Component::read_alert_state_() { + uint16_t config; + if (!this->read_byte_16(TMP102_REGISTER_CONFIGURATION, &config)) { + this->set_warning_and_invalidate_alert_(); + ESP_LOGW(TAG, "Failed to read alert state"); + return false; + } + // AL equals POL while the comparator status is active. + const bool alert = bool(config & TMP102_ALERT_BIT) == bool(config & TMP102_ALERT_POLARITY_BIT); + this->alert_binary_sensor_->publish_state(alert); + return true; +} +#endif + } // namespace esphome::tmp102 diff --git a/esphome/components/tmp102/tmp102.h b/esphome/components/tmp102/tmp102.h index f9eda32e98..caf75e19be 100644 --- a/esphome/components/tmp102/tmp102.h +++ b/esphome/components/tmp102/tmp102.h @@ -1,15 +1,57 @@ #pragma once #include "esphome/core/component.h" -#include "esphome/components/sensor/sensor.h" +#include "esphome/core/defines.h" +#ifdef USE_TMP102_BINARY_SENSOR +#include "esphome/components/binary_sensor/binary_sensor.h" +#endif #include "esphome/components/i2c/i2c.h" +#include "esphome/components/sensor/sensor.h" + +#include namespace esphome::tmp102 { class TMP102Component final : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { public: +#ifdef USE_TMP102_BINARY_SENSOR + SUB_BINARY_SENSOR(alert) +#endif +#ifdef USE_TMP102_CONFIGURE + void setup() override; + void set_configuration(uint16_t config, uint16_t high_limit, uint16_t low_limit) { + this->config_ = config; + this->high_limit_ = high_limit; + this->low_limit_ = low_limit; + this->configure_ = true; + } +#endif void dump_config() override; void update() override; + + protected: + void set_warning_and_invalidate_alert_() { + this->status_set_warning(); +#ifdef USE_TMP102_BINARY_SENSOR + this->invalidate_alert_(); +#endif + } +#ifdef USE_TMP102_BINARY_SENSOR + bool read_alert_state_(); + void invalidate_alert_(); +#endif +#ifdef USE_TMP102_CONFIGURE + bool write_register_(uint8_t reg, uint16_t value); + bool write_configuration_(); + bool check_configuration_(); + void read_temperature_(); + void update_configured_(); + + uint16_t config_{0}; + uint16_t high_limit_{0}; + uint16_t low_limit_{0}; + bool configure_{false}; +#endif }; } // namespace esphome::tmp102 diff --git a/esphome/components/tmp1075/__init__.py b/esphome/components/tmp1075/__init__.py index ddd04ad11a..81c0e50033 100644 --- a/esphome/components/tmp1075/__init__.py +++ b/esphome/components/tmp1075/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@sybrenstuvel"] +DOMAIN = "tmp1075" 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/tmp1075/tmp1075.cpp b/esphome/components/tmp1075/tmp1075.cpp index 681603d113..9b152c1ad3 100644 --- a/esphome/components/tmp1075/tmp1075.cpp +++ b/esphome/components/tmp1075/tmp1075.cpp @@ -3,7 +3,7 @@ namespace esphome::tmp1075 { -static const char *const TAG = "tmp1075"; +ESPHOME_LOG_TAG(TAG, "tmp1075"); constexpr uint8_t REG_TEMP = 0x0; // Temperature result constexpr uint8_t REG_CFGR = 0x1; // Configuration diff --git a/esphome/components/tmp117/__init__.py b/esphome/components/tmp117/__init__.py index e69de29bb2..e7429bd52e 100644 --- a/esphome/components/tmp117/__init__.py +++ b/esphome/components/tmp117/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tmp117" diff --git a/esphome/components/tmp117/tmp117.cpp b/esphome/components/tmp117/tmp117.cpp index b3e900f5b6..5925b297c2 100644 --- a/esphome/components/tmp117/tmp117.cpp +++ b/esphome/components/tmp117/tmp117.cpp @@ -6,7 +6,7 @@ namespace esphome::tmp117 { -static const char *const TAG = "tmp117"; +ESPHOME_LOG_TAG(TAG, "tmp117"); void TMP117Component::update() { int16_t data; diff --git a/esphome/components/tof10120/__init__.py b/esphome/components/tof10120/__init__.py index e69de29bb2..89b7cffed4 100644 --- a/esphome/components/tof10120/__init__.py +++ b/esphome/components/tof10120/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tof10120" diff --git a/esphome/components/tof10120/tof10120_sensor.cpp b/esphome/components/tof10120/tof10120_sensor.cpp index 290bc76a52..3943b37150 100644 --- a/esphome/components/tof10120/tof10120_sensor.cpp +++ b/esphome/components/tof10120/tof10120_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::tof10120 { -static const char *const TAG = "tof10120"; +ESPHOME_LOG_TAG(TAG, "tof10120"); static const uint8_t TOF10120_READ_DISTANCE_CMD[] = {0x00}; static const uint8_t TOF10120_DEFAULT_DELAY = 30; diff --git a/esphome/components/tormatic/__init__.py b/esphome/components/tormatic/__init__.py index 7f3f05a3cd..e26792c1d3 100644 --- a/esphome/components/tormatic/__init__.py +++ b/esphome/components/tormatic/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@ti-mo"] +DOMAIN = "tormatic" diff --git a/esphome/components/tormatic/tormatic_cover.cpp b/esphome/components/tormatic/tormatic_cover.cpp index 5c8d6623b6..fe7b4cf112 100644 --- a/esphome/components/tormatic/tormatic_cover.cpp +++ b/esphome/components/tormatic/tormatic_cover.cpp @@ -7,7 +7,7 @@ using namespace std; namespace esphome::tormatic { -static const char *const TAG = "tormatic.cover"; +ESPHOME_LOG_TAG(TAG, "tormatic.cover"); // Time to poll the UART when flushing after desync. At 9600 baud, a full // 12-byte message takes ~12.5ms, so 15ms guarantees all bytes have arrived. diff --git a/esphome/components/toshiba/__init__.py b/esphome/components/toshiba/__init__.py index e69de29bb2..1e5ec1fc27 100644 --- a/esphome/components/toshiba/__init__.py +++ b/esphome/components/toshiba/__init__.py @@ -0,0 +1 @@ +DOMAIN = "toshiba" diff --git a/esphome/components/toshiba/climate.py b/esphome/components/toshiba/climate.py index e5f8544f2f..69fbb21030 100644 --- a/esphome/components/toshiba/climate.py +++ b/esphome/components/toshiba/climate.py @@ -16,6 +16,8 @@ MODELS = { "RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C, "RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F, "RAS-2819T": Model.MODEL_RAS_2819T, + "RAS-B10N3KV2": Model.MODEL_RAS_B10N3KV2, + "SEIYA": Model.MODEL_SEIYA, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend( diff --git a/esphome/components/toshiba/toshiba.cpp b/esphome/components/toshiba/toshiba.cpp index 19950bbd15..406a1bc55d 100644 --- a/esphome/components/toshiba/toshiba.cpp +++ b/esphome/components/toshiba/toshiba.cpp @@ -1,5 +1,6 @@ #include "toshiba.h" #include "esphome/components/remote_base/toshiba_ac_protocol.h" +#include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include @@ -11,7 +12,7 @@ struct RacPt1411hwruFanSpeed { uint8_t code2; }; -static const char *const TAG = "toshiba.climate"; +ESPHOME_LOG_TAG(TAG, "toshiba.climate"); // Timings for IR bits/data const uint16_t TOSHIBA_HEADER_MARK = 4380; const uint16_t TOSHIBA_HEADER_SPACE = 4370; @@ -163,6 +164,54 @@ const uint8_t RAS_2819T_AUTO_DRY_FAN_BYTE = 0x65; const uint8_t RAS_2819T_AUTO_DRY_SUFFIX = 0x3A; const uint8_t RAS_2819T_HEAT_SUFFIX = 0x3B; +// Bit timings differ per protocol family; the frame layout is shared +struct ToshibaTimings { + uint16_t header_mark; + uint16_t header_space; + uint16_t bit_mark; + uint16_t zero_space; + uint16_t one_space; + uint16_t gap_space; + bool gap_after_last; // Seiya ends on a bare mark and only puts the gap between repeats +}; +// Kept in flash on ESP8266, where plain const objects referenced through a function would land in RAM +static const ToshibaTimings TOSHIBA_TIMINGS PROGMEM = {TOSHIBA_HEADER_MARK, + TOSHIBA_HEADER_SPACE, + TOSHIBA_BIT_MARK, + TOSHIBA_ZERO_SPACE, + TOSHIBA_ONE_SPACE, + TOSHIBA_GAP_SPACE, + true}; +static const ToshibaTimings SEIYA_TIMINGS PROGMEM = {4630, 4450, 625, 490, 1570, 5830, false}; +// The RAS-B13 remote family does not accept the generic timings; these are IRremoteESP8266's TOSHIBA_AC values +static const ToshibaTimings RAS_B10N3KV2_TIMINGS PROGMEM = {4400, 4300, 580, 490, 1600, 7400, true}; + +static ToshibaTimings timings_for(Model model) { + const ToshibaTimings *source = &TOSHIBA_TIMINGS; + switch (model) { + case MODEL_SEIYA: + source = &SEIYA_TIMINGS; + break; + case MODEL_RAS_B10N3KV2: + source = &RAS_B10N3KV2_TIMINGS; + break; + default: + break; + } + ToshibaTimings timings; + progmem_memcpy(&timings, source, sizeof(timings)); + return timings; +} + +// Seiya is the generic frame plus two bytes: a feature marker and a one-shot swing command +static constexpr uint8_t SEIYA_MESSAGE_LENGTH = 11; +static constexpr uint8_t SEIYA_FEATURE_MARKER = 0x21; +static constexpr uint8_t SEIYA_SWING_BOTH = 0x01; +static constexpr uint8_t SEIYA_SWING_OMNI = 0x02; // no ESPHome equivalent; treated as BOTH +static constexpr uint8_t SEIYA_SWING_VERTICAL = 0x08; +static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09; +static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame + // RAS-2819T temperature codes for 18-30°C static const uint8_t RAS_2819T_TEMP_CODES[] = { 0x10, // 18°C @@ -404,12 +453,15 @@ void ToshibaClimate::setup() { this->maximum_temperature_ = this->temperature_max_(); this->swing_modes_ = this->toshiba_swing_modes_(); - // Ensure swing mode is always initialized to a valid value + // Ensure swing mode is always initialized to a valid value. OFF also stands for "not yet + // commanded" on models that cannot switch the swing off, such as Seiya. if (this->swing_modes_.empty() || !this->swing_modes_.count(this->swing_mode)) { - // No swing support for this model or current swing mode not supported, reset to OFF this->swing_mode = climate::CLIMATE_SWING_OFF; } + // A restored swing mode is already in effect, so it must not go out as a fresh swing command + this->last_swing_mode_ = this->swing_mode; + // Ensure mode is valid - ESPHome should only use standard climate modes if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_HEAT && this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT_COOL && @@ -451,8 +503,9 @@ void ToshibaClimate::transmit_state() { } void ToshibaClimate::transmit_generic_() { + const bool seiya = this->model_ == MODEL_SEIYA; uint8_t message[16] = {0}; - uint8_t message_length = 9; + const uint8_t message_length = seiya ? SEIYA_MESSAGE_LENGTH : 9; // Header message[0] = 0xf2; @@ -473,6 +526,40 @@ void ToshibaClimate::transmit_generic_() { message[5] = (temperature - static_cast(TOSHIBA_GENERIC_TEMP_C_MIN)) << 4; // Mode and fan + message[6] = this->encode_mode_fan_(); + + // Zero + message[7] = 0x00; + + if (seiya) { + // Captured OFF frames carry no fan bits + if (this->mode == climate::CLIMATE_MODE_OFF) { + message[6] = TOSHIBA_MODE_OFF; + } + message[8] = SEIYA_FEATURE_MARKER; + // Swing is a momentary command, sent only when the mode changes, like the remote does + message[9] = SEIYA_SWING_NEUTRAL; + if (this->swing_mode != this->last_swing_mode_) { + message[9] = this->seiya_swing_code_(); + this->last_swing_mode_ = this->swing_mode; + } + } + + // The last byte is the xor of all bytes from [4] + for (uint8_t i = 4; i < message_length - 1; i++) { + message[message_length - 1] ^= message[i]; + } + + // Transmit + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + + this->encode_(data, message, message_length, 1); + + transmit.perform(); +} + +uint8_t ToshibaClimate::encode_mode_fan_() const { uint8_t mode; switch (this->mode) { case climate::CLIMATE_MODE_OFF: @@ -523,27 +610,20 @@ void ToshibaClimate::transmit_generic_() { fan = TOSHIBA_FAN_SPEED_AUTO; break; } - message[6] = fan | mode; + return fan | mode; +} - // Zero - message[7] = 0x00; - - // If timers bit in the command is set, two extra bytes are added here - - // If power bit is set in the command, one extra byte is added here - - // The last byte is the xor of all bytes from [4] - for (uint8_t i = 4; i < 8; i++) { - message[8] ^= message[i]; +uint8_t ToshibaClimate::seiya_swing_code_() const { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + return SEIYA_SWING_VERTICAL; + case climate::CLIMATE_SWING_HORIZONTAL: + return SEIYA_SWING_HORIZONTAL; + case climate::CLIMATE_SWING_BOTH: + return SEIYA_SWING_BOTH; + default: + return SEIYA_SWING_NEUTRAL; } - - // Transmit - auto transmit = this->transmitter_->transmit(); - auto *data = transmit.get_data(); - - this->encode_(data, message, message_length, 1); - - transmit.perform(); } void ToshibaClimate::transmit_rac_pt1411hwru_() { @@ -972,8 +1052,6 @@ bool ToshibaClimate::compare_rac_pt1411hwru_packets_(const uint8_t *message1, co } bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { - uint8_t checksum = 0; - switch (this->is_valid_rac_pt1411hwru_header_(message)) { case RAC_PT1411HWRU_MESSAGE_HEADER0: case RAC_PT1411HWRU_CS_HEADER: @@ -985,10 +1063,7 @@ bool ToshibaClimate::is_valid_rac_pt1411hwru_message_(const uint8_t *message) { break; case RAC_PT1411HWRU_MESSAGE_HEADER1: - for (uint8_t i = 0; i < RAC_PT1411HWRU_MESSAGE_LENGTH - 1; i++) { - checksum += message[i]; - } - if (checksum == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { + if (sum_bytes(message, RAC_PT1411HWRU_MESSAGE_LENGTH - 1) == message[RAC_PT1411HWRU_MESSAGE_LENGTH - 1]) { return true; } break; @@ -1085,14 +1160,16 @@ bool ToshibaClimate::process_ras_2819t_command_(const remote_base::ToshibaAcData bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { // Try modern ToshibaAcProtocol decoder first (handles RAS-2819T and potentially others) - remote_base::ToshibaAcProtocol toshiba_protocol; - auto decode_result = toshiba_protocol.decode(data); + if (this->model_ != MODEL_SEIYA) { + remote_base::ToshibaAcProtocol toshiba_protocol; + auto decode_result = toshiba_protocol.decode(data); - if (decode_result.has_value()) { - auto toshiba_data = decode_result.value(); - // Validate and process RAS-2819T commands - if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { - return this->process_ras_2819t_command_(toshiba_data); + if (decode_result.has_value()) { + auto toshiba_data = decode_result.value(); + // Validate and process RAS-2819T commands + if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) { + return this->process_ras_2819t_command_(toshiba_data); + } } } @@ -1101,7 +1178,8 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { uint8_t message_length = TOSHIBA_HEADER_LENGTH, temperature_code = 0; // Validate header - if (!data.expect_item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE)) { + const ToshibaTimings timings = timings_for(this->model_); + if (!data.expect_item(timings.header_mark, timings.header_space)) { return false; } // Read incoming bits into buffer @@ -1117,6 +1195,10 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } else { // First checksum was valid so continue receiving the remaining bits + // The shortest frame is 9 bytes (length byte 3); longer ones must still fit the buffer + if (message[2] < 3 || message[2] > sizeof(message) - TOSHIBA_HEADER_LENGTH - 2) { + return false; + } message_length = message[2] + 2; } // Decode the remaining bytes @@ -1276,59 +1358,11 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { return false; } - // Get the mode - switch (message[6] & 0x0F) { - case TOSHIBA_MODE_OFF: - this->mode = climate::CLIMATE_MODE_OFF; - break; - - case TOSHIBA_MODE_COOL: - this->mode = climate::CLIMATE_MODE_COOL; - break; - - case TOSHIBA_MODE_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - - case TOSHIBA_MODE_FAN_ONLY: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - - case TOSHIBA_MODE_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - - case TOSHIBA_MODE_AUTO: - default: - this->mode = climate::CLIMATE_MODE_HEAT_COOL; + this->decode_mode_fan_temperature_(message); + // The marker byte tells a Seiya frame from another checksum-valid 11 byte frame within receiver tolerance + if (this->model_ == MODEL_SEIYA && message_length == SEIYA_MESSAGE_LENGTH && message[8] == SEIYA_FEATURE_MARKER) { + this->seiya_decode_swing_(message[9]); } - - // Get the fan mode - switch (message[6] & 0xF0) { - case TOSHIBA_FAN_SPEED_QUIET: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - - case TOSHIBA_FAN_SPEED_1: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - - case TOSHIBA_FAN_SPEED_3: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - - case TOSHIBA_FAN_SPEED_5: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - - case TOSHIBA_FAN_SPEED_AUTO: - default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; - } - - // Get the target temperature - this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; } this->publish_state(); @@ -1337,31 +1371,36 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) { void ToshibaClimate::encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, const uint8_t nbytes, const uint8_t repeat) { + const ToshibaTimings timings = timings_for(this->model_); data->set_carrier_frequency(TOSHIBA_CARRIER_FREQUENCY); for (uint8_t copy = 0; copy <= repeat; copy++) { - data->item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE); + data->item(timings.header_mark, timings.header_space); for (uint8_t byte = 0; byte < nbytes; byte++) { for (uint8_t bit = 0; bit < 8; bit++) { - data->mark(TOSHIBA_BIT_MARK); + data->mark(timings.bit_mark); if (message[byte] & (1 << (7 - bit))) { - data->space(TOSHIBA_ONE_SPACE); + data->space(timings.one_space); } else { - data->space(TOSHIBA_ZERO_SPACE); + data->space(timings.zero_space); } } } - data->item(TOSHIBA_BIT_MARK, TOSHIBA_GAP_SPACE); + data->mark(timings.bit_mark); + if (copy < repeat || timings.gap_after_last) { + data->space(timings.gap_space); + } } } bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *message, const uint8_t nbytes) { + const ToshibaTimings timings = timings_for(this->model_); for (uint8_t byte = 0; byte < nbytes; byte++) { for (uint8_t bit = 0; bit < 8; bit++) { - if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ONE_SPACE)) { + if (data->expect_item(timings.bit_mark, timings.one_space)) { message[byte] |= 1 << (7 - bit); - } else if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ZERO_SPACE)) { + } else if (data->expect_item(timings.bit_mark, timings.zero_space)) { message[byte] &= static_cast(~(1 << (7 - bit))); } else { return false; @@ -1371,4 +1410,80 @@ bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *mess return true; } +void ToshibaClimate::decode_mode_fan_temperature_(const uint8_t *message) { + switch (message[6] & 0x0F) { + case TOSHIBA_MODE_OFF: + this->mode = climate::CLIMATE_MODE_OFF; + break; + + case TOSHIBA_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + + case TOSHIBA_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + + case TOSHIBA_MODE_FAN_ONLY: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + + case TOSHIBA_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + + case TOSHIBA_MODE_AUTO: + default: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + } + + // Speeds 2 and 4 are not exposed; report the nearest mode + switch (message[6] & 0xF0) { + case TOSHIBA_FAN_SPEED_QUIET: + this->fan_mode = climate::CLIMATE_FAN_QUIET; + break; + + case TOSHIBA_FAN_SPEED_1: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + + case TOSHIBA_FAN_SPEED_2: + case TOSHIBA_FAN_SPEED_3: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + + case TOSHIBA_FAN_SPEED_4: + case TOSHIBA_FAN_SPEED_5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + + case TOSHIBA_FAN_SPEED_AUTO: + default: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN; +} + +void ToshibaClimate::seiya_decode_swing_(uint8_t code) { + // A momentary command: the neutral value on an ordinary update means no change, not swing off + switch (code) { + case SEIYA_SWING_BOTH: + case SEIYA_SWING_OMNI: + this->swing_mode = climate::CLIMATE_SWING_BOTH; + break; + case SEIYA_SWING_VERTICAL: + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + break; + case SEIYA_SWING_HORIZONTAL: + this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; + break; + default: + return; + } + // Keep the tracker in sync so the received command is not sent back out + this->last_swing_mode_ = this->swing_mode; +} + } // namespace esphome::toshiba diff --git a/esphome/components/toshiba/toshiba.h b/esphome/components/toshiba/toshiba.h index a853730f31..799f690f54 100644 --- a/esphome/components/toshiba/toshiba.h +++ b/esphome/components/toshiba/toshiba.h @@ -11,6 +11,8 @@ enum Model { MODEL_RAC_PT1411HWRU_C = 1, // Temperature range is from 16 to 30 MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30 MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30 + MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte + MODEL_RAS_B10N3KV2 = 5, // RAS-B13 remote family, generic frame with IRremoteESP8266's TOSHIBA_AC timings }; // Supported temperature ranges @@ -50,7 +52,7 @@ class ToshibaClimate final : public climate_ir::ClimateIR { bool on_receive(remote_base::RemoteReceiveData data) override; private: - // RAS-2819T state tracking for swing mode optimization + // Last swing mode sent or received, for models whose swing is a one-shot command climate::ClimateSwingMode last_swing_mode_{climate::CLIMATE_SWING_OFF}; climate::ClimateMode last_mode_{climate::CLIMATE_MODE_OFF}; optional last_fan_mode_{}; @@ -71,13 +73,24 @@ class ToshibaClimate final : public climate_ir::ClimateIR { return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models } climate::ClimateSwingModeMask toshiba_swing_modes_() { - return (this->model_ == MODEL_GENERIC) - ? climate::ClimateSwingModeMask() - : climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; + if (this->model_ == MODEL_GENERIC || this->model_ == MODEL_RAS_B10N3KV2) + return climate::ClimateSwingModeMask(); + if (this->model_ == MODEL_SEIYA) { + // No captured code stops the swing, so OFF is not offered; the remote only selects a direction + return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL, + climate::CLIMATE_SWING_BOTH}; + } + return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL}; } void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat); bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes); + // Shared by every model that uses the F2 0D frame + uint8_t encode_mode_fan_() const; + void decode_mode_fan_temperature_(const uint8_t *message); + uint8_t seiya_swing_code_() const; + void seiya_decode_swing_(uint8_t code); + Model model_; }; diff --git a/esphome/components/total_daily_energy/__init__.py b/esphome/components/total_daily_energy/__init__.py index e69de29bb2..d3880a5084 100644 --- a/esphome/components/total_daily_energy/__init__.py +++ b/esphome/components/total_daily_energy/__init__.py @@ -0,0 +1 @@ +DOMAIN = "total_daily_energy" diff --git a/esphome/components/total_daily_energy/total_daily_energy.cpp b/esphome/components/total_daily_energy/total_daily_energy.cpp index 7c9dbb604f..c8e6bdd04a 100644 --- a/esphome/components/total_daily_energy/total_daily_energy.cpp +++ b/esphome/components/total_daily_energy/total_daily_energy.cpp @@ -4,7 +4,7 @@ namespace esphome::total_daily_energy { -static const char *const TAG = "total_daily_energy"; +ESPHOME_LOG_TAG(TAG, "total_daily_energy"); static constexpr uint32_t TIMEOUT_ID_MIDNIGHT = 1; static constexpr uint8_t SECONDS_PER_MINUTE = 60; static constexpr uint8_t MINUTES_PER_HOUR = 60; diff --git a/esphome/components/touchscreen/__init__.py b/esphome/components/touchscreen/__init__.py index c8b918007b..7a23d14069 100644 --- a/esphome/components/touchscreen/__init__.py +++ b/esphome/components/touchscreen/__init__.py @@ -23,6 +23,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@jesserockz", "@nielsnl68"] DEPENDENCIES = ["display"] +DOMAIN = "touchscreen" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/touchscreen/touchscreen.cpp b/esphome/components/touchscreen/touchscreen.cpp index f4ef66ef3e..7072b88d55 100644 --- a/esphome/components/touchscreen/touchscreen.cpp +++ b/esphome/components/touchscreen/touchscreen.cpp @@ -4,7 +4,8 @@ namespace esphome::touchscreen { -static const char *const TAG = "touchscreen"; +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/tsl2561/__init__.py index e69de29bb2..9986b7fd3e 100644 --- a/esphome/components/tsl2561/__init__.py +++ b/esphome/components/tsl2561/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tsl2561" diff --git a/esphome/components/tsl2561/tsl2561.cpp b/esphome/components/tsl2561/tsl2561.cpp index 4e4d403488..d1d1d66a26 100644 --- a/esphome/components/tsl2561/tsl2561.cpp +++ b/esphome/components/tsl2561/tsl2561.cpp @@ -3,7 +3,9 @@ namespace esphome::tsl2561 { -static const char *const TAG = "tsl2561"; +ESPHOME_LOG_TAG(TAG, "tsl2561"); + +static constexpr uint32_t READ_TIMEOUT_ID = 0; static const uint8_t TSL2561_COMMAND_BIT = 0x80; static const uint8_t TSL2561_WORD_BIT = 0x20; @@ -60,7 +62,7 @@ void TSL2561Sensor::update() { // Make sure the data is there when we will read it. auto timeout = static_cast(this->get_integration_time_ms_() + 20); - this->set_timeout("illuminance", timeout, [this]() { this->read_data_(); }); + this->set_timeout(READ_TIMEOUT_ID, timeout, [this]() { this->read_data_(); }); } float TSL2561Sensor::calculate_lx_(uint16_t ch0, uint16_t ch1) { diff --git a/esphome/components/tsl2591/__init__.py b/esphome/components/tsl2591/__init__.py index 63331641c5..c611ad0ebd 100644 --- a/esphome/components/tsl2591/__init__.py +++ b/esphome/components/tsl2591/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@wjcarpenter"] +DOMAIN = "tsl2591" 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 d147aae88a..f1f9ae9cfd 100644 --- a/esphome/components/tsl2591/tsl2591.cpp +++ b/esphome/components/tsl2591/tsl2591.cpp @@ -4,7 +4,9 @@ namespace esphome::tsl2591 { -static const char *const TAG = "tsl2591.sensor"; +ESPHOME_LOG_TAG(TAG, "tsl2591.sensor"); + +static constexpr uint32_t ADC_WAIT_INTERVAL_ID = 0; // Various constants used in TSL2591 register manipulation #define TSL2591_COMMAND_BIT (0xA0) // 1010 0000: bits 7 and 5 for 'command, normal' @@ -163,19 +165,17 @@ void TSL2591Component::process_update_() { this->status_clear_warning(); } -#define interval_name "tsl2591_interval_for_update" - void TSL2591Component::interval_function_for_update_() { if (!this->is_adc_valid()) { uint64_t now = millis(); ESP_LOGD(TAG, "Elapsed %3llu ms; still waiting for valid ADC", (now - this->interval_start_)); if (now > this->interval_timeout_) { ESP_LOGW(TAG, "Interval timeout for '%s' expired before ADCs became valid", this->name_); - this->cancel_interval(interval_name); + this->cancel_interval(ADC_WAIT_INTERVAL_ID); } return; } - this->cancel_interval(interval_name); + this->cancel_interval(ADC_WAIT_INTERVAL_ID); this->process_update_(); } @@ -191,7 +191,7 @@ void TSL2591Component::update() { } else { this->interval_start_ = millis(); this->interval_timeout_ = this->interval_start_ + 620; - this->set_interval(interval_name, 100, [this] { this->interval_function_for_update_(); }); + this->set_interval(ADC_WAIT_INTERVAL_ID, 100, [this] { this->interval_function_for_update_(); }); } } } diff --git a/esphome/components/tt21100/__init__.py b/esphome/components/tt21100/__init__.py index a309d34beb..a8f0ec0dd3 100644 --- a/esphome/components/tt21100/__init__.py +++ b/esphome/components/tt21100/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg CODEOWNERS = ["@kroimon"] +DOMAIN = "tt21100" tt21100_ns = cg.esphome_ns.namespace("tt21100") diff --git a/esphome/components/tt21100/binary_sensor/tt21100_button.cpp b/esphome/components/tt21100/binary_sensor/tt21100_button.cpp index ccf6e53d66..ea388703e0 100644 --- a/esphome/components/tt21100/binary_sensor/tt21100_button.cpp +++ b/esphome/components/tt21100/binary_sensor/tt21100_button.cpp @@ -3,7 +3,7 @@ namespace esphome::tt21100 { -static const char *const TAG = "tt21100.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "tt21100.binary_sensor"); void TT21100Button::setup() { this->parent_->register_button_listener(this); diff --git a/esphome/components/tt21100/touchscreen/tt21100.cpp b/esphome/components/tt21100/touchscreen/tt21100.cpp index 018094df73..b850dbc696 100644 --- a/esphome/components/tt21100/touchscreen/tt21100.cpp +++ b/esphome/components/tt21100/touchscreen/tt21100.cpp @@ -3,7 +3,7 @@ namespace esphome::tt21100 { -static const char *const TAG = "tt21100"; +ESPHOME_LOG_TAG(TAG, "tt21100"); static const uint8_t MAX_BUTTONS = 4; static const uint8_t MAX_TOUCH_POINTS = 5; diff --git a/esphome/components/ttp229_bsf/__init__.py b/esphome/components/ttp229_bsf/__init__.py index 9e2a4ad114..1b10940570 100644 --- a/esphome/components/ttp229_bsf/__init__.py +++ b/esphome/components/ttp229_bsf/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID, CONF_SCL_PIN, CONF_SDO_PIN from esphome.types import ConfigType AUTO_LOAD = ["binary_sensor"] +DOMAIN = "ttp229_bsf" CONF_TTP229_ID = "ttp229_id" ttp229_bsf_ns = cg.esphome_ns.namespace("ttp229_bsf") diff --git a/esphome/components/ttp229_bsf/ttp229_bsf.cpp b/esphome/components/ttp229_bsf/ttp229_bsf.cpp index 1c7fa3531f..807d736557 100644 --- a/esphome/components/ttp229_bsf/ttp229_bsf.cpp +++ b/esphome/components/ttp229_bsf/ttp229_bsf.cpp @@ -3,7 +3,7 @@ namespace esphome::ttp229_bsf { -static const char *const TAG = "ttp229_bsf"; +ESPHOME_LOG_TAG(TAG, "ttp229_bsf"); void TTP229BSFComponent::setup() { this->sdo_pin_->setup(); diff --git a/esphome/components/ttp229_lsf/__init__.py b/esphome/components/ttp229_lsf/__init__.py index d32f72fcce..e89b177a3e 100644 --- a/esphome/components/ttp229_lsf/__init__.py +++ b/esphome/components/ttp229_lsf/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["binary_sensor"] +DOMAIN = "ttp229_lsf" CONF_TTP229_ID = "ttp229_id" ttp229_lsf_ns = cg.esphome_ns.namespace("ttp229_lsf") diff --git a/esphome/components/ttp229_lsf/ttp229_lsf.cpp b/esphome/components/ttp229_lsf/ttp229_lsf.cpp index eaef33d793..7ed06c6d6f 100644 --- a/esphome/components/ttp229_lsf/ttp229_lsf.cpp +++ b/esphome/components/ttp229_lsf/ttp229_lsf.cpp @@ -3,7 +3,7 @@ namespace esphome::ttp229_lsf { -static const char *const TAG = "ttp229_lsf"; +ESPHOME_LOG_TAG(TAG, "ttp229_lsf"); void TTP229LSFComponent::setup() { uint8_t data[2]; diff --git a/esphome/components/tuya/__init__.py b/esphome/components/tuya/__init__.py index 2cc60e225c..c05db51cdf 100644 --- a/esphome/components/tuya/__init__.py +++ b/esphome/components/tuya/__init__.py @@ -2,20 +2,26 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import time, uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID, CONF_TRIGGER_ID +from esphome.const import CONF_ID, CONF_SENSOR_DATAPOINT, CONF_TIME_ID +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType DEPENDENCIES = ["uart"] +DOMAIN = "tuya" CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS = "ignore_mcu_update_on_datapoints" CONF_ON_DATAPOINT_UPDATE = "on_datapoint_update" CONF_DATAPOINT_TYPE = "datapoint_type" CONF_STATUS_PIN = "status_pin" +CONF_FORCE_TIME_SYNC = "force_time_sync" tuya_ns = cg.esphome_ns.namespace("tuya") TuyaDatapointType = tuya_ns.enum("TuyaDatapointType", is_class=True) Tuya = tuya_ns.class_("Tuya", cg.Component, uart.UARTDevice) +TuyaDatapoint = tuya_ns.struct("TuyaDatapoint") + DPTYPE_ANY = "any" DPTYPE_RAW = "raw" DPTYPE_BOOL = "bool" @@ -25,87 +31,54 @@ DPTYPE_STRING = "string" DPTYPE_ENUM = "enum" DPTYPE_BITMASK = "bitmask" +# Automation argument type, plus the expected TuyaDatapointType and the field forwarded DATAPOINT_TYPES = { - DPTYPE_ANY: tuya_ns.struct("TuyaDatapoint"), - DPTYPE_RAW: cg.std_vector.template(cg.uint8), - DPTYPE_BOOL: cg.bool_, - DPTYPE_INT: cg.int_, - DPTYPE_UINT: cg.uint32, - DPTYPE_STRING: cg.std_string, - DPTYPE_ENUM: cg.uint8, - DPTYPE_BITMASK: cg.uint32, + DPTYPE_ANY: (TuyaDatapoint, None, None), + DPTYPE_RAW: (cg.std_vector.template(cg.uint8), TuyaDatapointType.RAW, "value_raw"), + DPTYPE_BOOL: (cg.bool_, TuyaDatapointType.BOOLEAN, "value_bool"), + DPTYPE_INT: (cg.int_, TuyaDatapointType.INTEGER, "value_int"), + DPTYPE_UINT: (cg.uint32, TuyaDatapointType.INTEGER, "value_uint"), + DPTYPE_STRING: (cg.std_string, TuyaDatapointType.STRING, "value_string"), + DPTYPE_ENUM: (cg.uint8, TuyaDatapointType.ENUM, "value_enum"), + DPTYPE_BITMASK: (cg.uint32, TuyaDatapointType.BITMASK, "value_bitmask"), } -DATAPOINT_TRIGGERS = { - DPTYPE_ANY: tuya_ns.class_( - "TuyaDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_ANY]), - ), - DPTYPE_RAW: tuya_ns.class_( - "TuyaRawDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_RAW]), - ), - DPTYPE_BOOL: tuya_ns.class_( - "TuyaBoolDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_BOOL]), - ), - DPTYPE_INT: tuya_ns.class_( - "TuyaIntDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_INT]), - ), - DPTYPE_UINT: tuya_ns.class_( - "TuyaUIntDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_UINT]), - ), - DPTYPE_STRING: tuya_ns.class_( - "TuyaStringDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_STRING]), - ), - DPTYPE_ENUM: tuya_ns.class_( - "TuyaEnumDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_ENUM]), - ), - DPTYPE_BITMASK: tuya_ns.class_( - "TuyaBitmaskDatapointUpdateTrigger", - automation.Trigger.template(DATAPOINT_TYPES[DPTYPE_BITMASK]), - ), -} - - -def assign_declare_id(value): - value = value.copy() - value[CONF_TRIGGER_ID] = cv.declare_id( - DATAPOINT_TRIGGERS[value[CONF_DATAPOINT_TYPE]] - )(value[CONF_TRIGGER_ID].id) - return value - CONF_TUYA_ID = "tuya_id" -CONFIG_SCHEMA = ( + + +def _validate_time_options(config: ConfigType) -> ConfigType: + if config[CONF_FORCE_TIME_SYNC] and CONF_TIME_ID not in config: + raise cv.Invalid( + f"{CONF_FORCE_TIME_SYNC} requires {CONF_TIME_ID}", + path=[CONF_FORCE_TIME_SYNC], + ) + return config + + +CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(): cv.declare_id(Tuya), cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + cv.Optional(CONF_FORCE_TIME_SYNC, default=False): cv.boolean, cv.Optional(CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS): cv.ensure_list( cv.uint8_t ), cv.Optional(CONF_STATUS_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_ON_DATAPOINT_UPDATE): automation.validate_automation( { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( - DATAPOINT_TRIGGERS[DPTYPE_ANY] - ), cv.Required(CONF_SENSOR_DATAPOINT): cv.uint8_t, cv.Optional(CONF_DATAPOINT_TYPE, default=DPTYPE_ANY): cv.one_of( - *DATAPOINT_TRIGGERS, lower=True + *DATAPOINT_TYPES, lower=True ), - }, - extra_validators=assign_declare_id, + } ), } ) .extend(cv.COMPONENT_SCHEMA) - .extend(uart.UART_DEVICE_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA), + _validate_time_options, ) @@ -113,9 +86,11 @@ async def to_code(config): var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) await uart.register_uart_device(var, config) - if CONF_TIME_ID in config: - time_ = await cg.get_variable(config[CONF_TIME_ID]) + if (time_id := config.get(CONF_TIME_ID)) is not None: + time_ = await cg.get_variable(time_id) cg.add(var.set_time_id(time_)) + if config[CONF_FORCE_TIME_SYNC]: + cg.add(var.set_force_time_sync(True)) if CONF_STATUS_PIN in config: status_pin_ = await cg.gpio_pin_expression(config[CONF_STATUS_PIN]) cg.add(var.set_status_pin(status_pin_)) @@ -123,9 +98,11 @@ async def to_code(config): for dp in config[CONF_IGNORE_MCU_UPDATE_ON_DATAPOINTS]: cg.add(var.add_ignore_mcu_update_on_datapoints(dp)) for conf in config.get(CONF_ON_DATAPOINT_UPDATE, []): - trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], var, conf[CONF_SENSOR_DATAPOINT] - ) - await automation.build_automation( - trigger, [(DATAPOINT_TYPES[conf[CONF_DATAPOINT_TYPE]], "x")], conf + type_, expected, field = DATAPOINT_TYPES[conf[CONF_DATAPOINT_TYPE]] + forward = None + if expected is not None: + forward = [getattr(MockObj("x", ".").expect_type(expected), field)] + callback = await automation.build_trigger_callback( + [(type_, "x")], conf, params=[(TuyaDatapoint, "x")], forward=forward ) + cg.add(var.register_listener(conf[CONF_SENSOR_DATAPOINT], callback)) diff --git a/esphome/components/tuya/automation.cpp b/esphome/components/tuya/automation.cpp deleted file mode 100644 index 5c84f36b83..0000000000 --- a/esphome/components/tuya/automation.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "esphome/core/log.h" - -#include "automation.h" - -static const char *const TAG = "tuya.automation"; - -namespace esphome::tuya { - -void check_expected_datapoint(const TuyaDatapoint &dp, TuyaDatapointType expected) { - if (dp.type != expected) { - ESP_LOGW(TAG, "Tuya sensor %u expected datapoint type %#02hhX but got %#02hhX", dp.id, - static_cast(expected), static_cast(dp.type)); - } -} - -TuyaRawDatapointUpdateTrigger::TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::RAW); - this->trigger(dp.value_raw); - }); -} - -TuyaBoolDatapointUpdateTrigger::TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::BOOLEAN); - this->trigger(dp.value_bool); - }); -} - -TuyaIntDatapointUpdateTrigger::TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::INTEGER); - this->trigger(dp.value_int); - }); -} - -TuyaUIntDatapointUpdateTrigger::TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::INTEGER); - this->trigger(dp.value_uint); - }); -} - -TuyaStringDatapointUpdateTrigger::TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::STRING); - this->trigger(dp.value_string); - }); -} - -TuyaEnumDatapointUpdateTrigger::TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::ENUM); - this->trigger(dp.value_enum); - }); -} - -TuyaBitmaskDatapointUpdateTrigger::TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { - check_expected_datapoint(dp, TuyaDatapointType::BITMASK); - this->trigger(dp.value_bitmask); - }); -} - -} // namespace esphome::tuya diff --git a/esphome/components/tuya/automation.h b/esphome/components/tuya/automation.h deleted file mode 100644 index 0cd63a76be..0000000000 --- a/esphome/components/tuya/automation.h +++ /dev/null @@ -1,53 +0,0 @@ -#pragma once - -#include "esphome/core/component.h" -#include "esphome/core/automation.h" -#include "tuya.h" - -#include - -namespace esphome::tuya { - -class TuyaDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id) { - parent->register_listener(sensor_id, [this](const TuyaDatapoint &dp) { this->trigger(dp); }); - } -}; - -class TuyaRawDatapointUpdateTrigger final : public Trigger> { - public: - explicit TuyaRawDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaBoolDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaBoolDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaIntDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaUIntDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaUIntDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaStringDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaStringDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaEnumDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaEnumDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -class TuyaBitmaskDatapointUpdateTrigger final : public Trigger { - public: - explicit TuyaBitmaskDatapointUpdateTrigger(Tuya *parent, uint8_t sensor_id); -}; - -} // namespace esphome::tuya diff --git a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp index f93bd31b9d..9d0589305e 100644 --- a/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp +++ b/esphome/components/tuya/binary_sensor/tuya_binary_sensor.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "tuya.binary_sensor"); void TuyaBinarySensor::setup() { this->parent_->register_listener(this->sensor_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/climate/tuya_climate.cpp b/esphome/components/tuya/climate/tuya_climate.cpp index 111d090c3e..e07806326c 100644 --- a/esphome/components/tuya/climate/tuya_climate.cpp +++ b/esphome/components/tuya/climate/tuya_climate.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.climate"; +ESPHOME_LOG_TAG(TAG, "tuya.climate"); void TuyaClimate::setup() { auto switch_id = this->switch_id_; diff --git a/esphome/components/tuya/cover/tuya_cover.cpp b/esphome/components/tuya/cover/tuya_cover.cpp index dd268388d0..b23dde75db 100644 --- a/esphome/components/tuya/cover/tuya_cover.cpp +++ b/esphome/components/tuya/cover/tuya_cover.cpp @@ -9,7 +9,7 @@ const uint8_t COMMAND_STOP = 0x01; using namespace esphome::cover; -static const char *const TAG = "tuya.cover"; +ESPHOME_LOG_TAG(TAG, "tuya.cover"); void TuyaCover::setup() { this->value_range_ = this->max_value_ - this->min_value_; diff --git a/esphome/components/tuya/fan/tuya_fan.cpp b/esphome/components/tuya/fan/tuya_fan.cpp index 0b5fc19038..8e4694baed 100644 --- a/esphome/components/tuya/fan/tuya_fan.cpp +++ b/esphome/components/tuya/fan/tuya_fan.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.fan"; +ESPHOME_LOG_TAG(TAG, "tuya.fan"); void TuyaFan::setup() { auto speed_id = this->speed_id_; diff --git a/esphome/components/tuya/light/tuya_light.cpp b/esphome/components/tuya/light/tuya_light.cpp index 9f3f3c13cc..4852e5ff44 100644 --- a/esphome/components/tuya/light/tuya_light.cpp +++ b/esphome/components/tuya/light/tuya_light.cpp @@ -4,12 +4,12 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.light"; +ESPHOME_LOG_TAG(TAG, "tuya.light"); void TuyaLight::setup() { if (this->color_temperature_id_.has_value()) { this->parent_->register_listener(*this->color_temperature_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -27,7 +27,7 @@ void TuyaLight::setup() { } if (this->dimmer_id_.has_value()) { this->parent_->register_listener(*this->dimmer_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -39,7 +39,7 @@ void TuyaLight::setup() { } if (switch_id_.has_value()) { this->parent_->register_listener(*this->switch_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } @@ -51,7 +51,7 @@ void TuyaLight::setup() { } if (color_id_.has_value()) { this->parent_->register_listener(*this->color_id_, [this](const TuyaDatapoint &datapoint) { - if (this->state_->current_values != this->state_->remote_values) { + if (this->state_->is_transitioning()) { ESP_LOGD(TAG, "Light is transitioning, datapoint change ignored"); return; } diff --git a/esphome/components/tuya/number/tuya_number.cpp b/esphome/components/tuya/number/tuya_number.cpp index b0bbfce649..538021bca2 100644 --- a/esphome/components/tuya/number/tuya_number.cpp +++ b/esphome/components/tuya/number/tuya_number.cpp @@ -5,7 +5,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.number"; +ESPHOME_LOG_TAG(TAG, "tuya.number"); void TuyaNumber::setup() { if (this->restore_value_) { diff --git a/esphome/components/tuya/select/tuya_select.cpp b/esphome/components/tuya/select/tuya_select.cpp index 057f7f8ea6..7c00c7c0f9 100644 --- a/esphome/components/tuya/select/tuya_select.cpp +++ b/esphome/components/tuya/select/tuya_select.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.select"; +ESPHOME_LOG_TAG(TAG, "tuya.select"); void TuyaSelect::setup() { this->parent_->register_listener(this->select_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/sensor/tuya_sensor.cpp b/esphome/components/tuya/sensor/tuya_sensor.cpp index c948984786..6b16d93080 100644 --- a/esphome/components/tuya/sensor/tuya_sensor.cpp +++ b/esphome/components/tuya/sensor/tuya_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.sensor"; +ESPHOME_LOG_TAG(TAG, "tuya.sensor"); void TuyaSensor::setup() { this->parent_->register_listener(this->sensor_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/switch/tuya_switch.cpp b/esphome/components/tuya/switch/tuya_switch.cpp index 8d4d183d5b..204ee84170 100644 --- a/esphome/components/tuya/switch/tuya_switch.cpp +++ b/esphome/components/tuya/switch/tuya_switch.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.switch"; +ESPHOME_LOG_TAG(TAG, "tuya.switch"); void TuyaSwitch::setup() { this->parent_->register_listener(this->switch_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp index ebb35cead7..2e64fb1dea 100644 --- a/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp +++ b/esphome/components/tuya/text_sensor/tuya_text_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.text_sensor"; +ESPHOME_LOG_TAG(TAG, "tuya.text_sensor"); void TuyaTextSensor::setup() { this->parent_->register_listener(this->sensor_id_, [this](const TuyaDatapoint &datapoint) { diff --git a/esphome/components/tuya/tuya.cpp b/esphome/components/tuya/tuya.cpp index f9b4fe2453..025b97ee72 100644 --- a/esphome/components/tuya/tuya.cpp +++ b/esphome/components/tuya/tuya.cpp @@ -18,12 +18,18 @@ namespace esphome::tuya { -static const char *const TAG = "tuya"; +ESPHOME_LOG_TAG(TAG, "tuya"); + +static constexpr uint32_t HEARTBEAT_INTERVAL_ID = 0; +static constexpr uint32_t WIFI_STATUS_INTERVAL_ID = 1; +static constexpr uint32_t DATAPOINT_DUMP_TIMEOUT_ID = 2; +static constexpr uint32_t FORCE_TIME_SYNC_INTERVAL_ID = 3; static const int COMMAND_DELAY = 10; static const int RECEIVE_TIMEOUT = 300; static const int MAX_RETRIES = 5; // Max bytes to log for datapoint values (larger values are truncated) static constexpr size_t MAX_DATAPOINT_LOG_BYTES = 16; +static constexpr uint32_t FORCE_TIME_SYNC_CHECK_INTERVAL = 1000; static bool network_is_connected() { #ifdef USE_NETWORK @@ -34,7 +40,14 @@ static bool network_is_connected() { } void Tuya::setup() { - this->set_interval("heartbeat", 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); + this->set_interval(HEARTBEAT_INTERVAL_ID, 15000, [this] { this->send_empty_command_(TuyaCommandType::HEARTBEAT); }); +#ifdef USE_TIME + if (this->force_time_sync_) { + this->register_local_time_sync_callback_(); + this->set_interval(FORCE_TIME_SYNC_INTERVAL_ID, FORCE_TIME_SYNC_CHECK_INTERVAL, + [this] { this->check_force_time_sync_(); }); + } +#endif if (this->status_pin_ != nullptr) { this->status_pin_->digital_write(false); } @@ -95,6 +108,11 @@ void Tuya::dump_config() { } LOG_PIN(" Status Pin: ", this->status_pin_); ESP_LOGCONFIG(TAG, " Product: '%s'", this->product_.c_str()); +#ifdef USE_TIME + if (this->time_id_ != nullptr) { + ESP_LOGCONFIG(TAG, " Force time sync: %s", YESNO(this->force_time_sync_)); + } +#endif } bool Tuya::validate_message_() { @@ -134,9 +152,7 @@ bool Tuya::validate_message_() { // Byte 6+LEN: CHECKSUM - sum of all bytes (including header) modulo 256 uint8_t rx_checksum = new_byte; - uint8_t calc_checksum = 0; - for (uint32_t i = 0; i < 6 + length; i++) - calc_checksum += data[i]; + const uint8_t calc_checksum = sum_bytes(data, 6 + length); if (rx_checksum != calc_checksum) { ESP_LOGW(TAG, "Tuya Received invalid message checksum %02X!=%02X", rx_checksum, calc_checksum); @@ -223,7 +239,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff this->status_pin_reported_); } ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin()); - this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); }); + this->set_interval(WIFI_STATUS_INTERVAL_ID, 1000, [this] { this->set_status_pin_(); }); } else { ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.", this->status_pin_reported_); @@ -231,7 +247,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff } else { this->init_state_ = TuyaInitState::INIT_WIFI; ESP_LOGV(TAG, "Configured WIFI_STATE periodic send"); - this->set_interval("wifi", 1000, [this] { this->send_wifi_status_(); }); + this->set_interval(WIFI_STATUS_INTERVAL_ID, 1000, [this] { this->send_wifi_status_(); }); } } break; @@ -279,7 +295,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff case TuyaCommandType::DATAPOINT_REPORT_SYNC: if (this->init_state_ == TuyaInitState::INIT_DATAPOINT) { this->init_state_ = TuyaInitState::INIT_DONE; - this->set_timeout("datapoint_dump", 1000, [this] { this->dump_config(); }); + this->set_timeout(DATAPOINT_DUMP_TIMEOUT_ID, 1000, [this] { this->dump_config(); }); this->initialized_callback_.call(); } this->handle_datapoints_(buffer, len); @@ -302,12 +318,7 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff #ifdef USE_TIME if (this->time_id_ != nullptr) { this->send_local_time_(); - - if (!this->time_sync_callback_registered_) { - // tuya mcu supports time, so we let them know when our time changed - this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); - this->time_sync_callback_registered_ = true; - } + this->register_local_time_sync_callback_(); } else #endif { @@ -478,7 +489,7 @@ void Tuya::handle_datapoints_(const uint8_t *buffer, size_t len) { // Run through listeners for (auto &listener : this->listeners_) { if (listener.datapoint_id == datapoint.id) - listener.on_datapoint(datapoint); + listener.on_datapoint.call(datapoint); } } } @@ -518,10 +529,8 @@ void Tuya::send_raw_command_(TuyaCommand command) { if (!command.payload.empty()) this->write_array(command.payload.data(), command.payload.size()); - uint8_t checksum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; - for (auto &data : command.payload) - checksum += data; - this->write_byte(checksum); + const uint8_t header_sum = 0x55 + 0xAA + (uint8_t) command.cmd + len_hi + len_lo; + this->write_byte(sum_bytes(command.payload.data(), command.payload.size(), header_sum)); } void Tuya::process_command_queue_() { @@ -612,7 +621,23 @@ void Tuya::send_wifi_status_() { } #ifdef USE_TIME -void Tuya::send_local_time_() { +void Tuya::register_local_time_sync_callback_() { + if (this->time_sync_callback_registered_) { + return; + } + this->time_id_->add_on_time_sync_callback([this] { this->send_local_time_(); }); + this->time_sync_callback_registered_ = true; +} + +void Tuya::check_force_time_sync_() { + ESPTime now = this->time_id_->now(); + if (!now.is_valid() || now.minute == this->last_force_time_sync_minute_) + return; + this->last_force_time_sync_minute_ = now.minute; + this->send_local_time_(true); +} + +void Tuya::send_local_time_(bool periodic) { std::vector payload; ESPTime now = this->time_id_->now(); if (now.is_valid()) { @@ -627,7 +652,12 @@ void Tuya::send_local_time_() { if (day_of_week == 0) { day_of_week = 7; } - ESP_LOGD(TAG, "Sending local time"); + // The forced send repeats every minute, so it stays out of the debug log + if (periodic) { + ESP_LOGV(TAG, "Sending local time"); + } else { + ESP_LOGD(TAG, "Sending local time"); + } payload = std::vector{0x01, year, month, day_of_month, hour, minute, second, day_of_week}; } else { // By spec we need to notify MCU that the time was not obtained if this is a response to a query @@ -792,20 +822,27 @@ void Tuya::send_datapoint_command_(uint8_t datapoint_id, TuyaDatapointType datap this->send_command_(TuyaCommand{.cmd = TuyaCommandType::DATAPOINT_DELIVER, .payload = buffer}); } -void Tuya::register_listener(uint8_t datapoint_id, const std::function &func) { - auto listener = TuyaDatapointListener{ +void Tuya::register_listener_(uint8_t datapoint_id, Callback func) { + this->listeners_.push_back(TuyaDatapointListener{ .datapoint_id = datapoint_id, .on_datapoint = func, - }; - this->listeners_.push_back(listener); + }); // Run through existing datapoints for (auto &datapoint : this->datapoints_) { if (datapoint.id == datapoint_id) - func(datapoint); + func.call(datapoint); } } +const TuyaDatapoint &TuyaDatapoint::expect_type(TuyaDatapointType expected) const { + if (this->type != expected) { + ESP_LOGW(TAG, "Tuya sensor %u expected datapoint type %#02hhX but got %#02hhX", this->id, + static_cast(expected), static_cast(this->type)); + } + return *this; +} + TuyaInitState Tuya::get_init_state() { return this->init_state_; } } // namespace esphome::tuya diff --git a/esphome/components/tuya/tuya.h b/esphome/components/tuya/tuya.h index b8bf4e0ab1..1e2e12200d 100644 --- a/esphome/components/tuya/tuya.h +++ b/esphome/components/tuya/tuya.h @@ -37,11 +37,14 @@ struct TuyaDatapoint { }; std::string value_string; std::vector value_raw; + + /// Log a warning when this datapoint is not of type `expected`, and return it. + const TuyaDatapoint &expect_type(TuyaDatapointType expected) const; }; struct TuyaDatapointListener { uint8_t datapoint_id; - std::function on_datapoint; + Callback on_datapoint; }; enum class TuyaCommandType : uint8_t { @@ -91,7 +94,9 @@ class Tuya final : public Component, public uart::UARTDevice { void setup() override; void loop() override; void dump_config() override; - void register_listener(uint8_t datapoint_id, const std::function &func); + template void register_listener(uint8_t datapoint_id, F &&func) { + this->register_listener_(datapoint_id, Callback::create(std::forward(func))); + } void set_raw_datapoint_value(uint8_t datapoint_id, const std::vector &value); void set_boolean_datapoint_value(uint8_t datapoint_id, bool value); void set_integer_datapoint_value(uint8_t datapoint_id, uint32_t value); @@ -108,6 +113,7 @@ class Tuya final : public Component, public uart::UARTDevice { TuyaInitState get_init_state(); #ifdef USE_TIME void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; } + void set_force_time_sync(bool force_time_sync) { this->force_time_sync_ = force_time_sync; } #endif void add_ignore_mcu_update_on_datapoints(uint8_t ignore_mcu_update_on_datapoints) { this->ignore_mcu_update_on_datapoints_.push_back(ignore_mcu_update_on_datapoints); @@ -117,6 +123,7 @@ class Tuya final : public Component, public uart::UARTDevice { } protected: + void register_listener_(uint8_t datapoint_id, Callback func); void handle_char_(uint8_t c); void handle_datapoints_(const uint8_t *buffer, size_t len); optional get_datapoint_(uint8_t datapoint_id); @@ -138,9 +145,13 @@ class Tuya final : public Component, public uart::UARTDevice { uint8_t get_wifi_rssi_(); #ifdef USE_TIME - void send_local_time_(); + void register_local_time_sync_callback_(); + void check_force_time_sync_(); + void send_local_time_(bool periodic = false); void send_gmt_time_(); time::RealTimeClock *time_id_{nullptr}; + bool force_time_sync_{false}; + uint8_t last_force_time_sync_minute_{0xFF}; // 0xFF: no minute seen yet bool time_sync_callback_registered_{false}; bool gmt_time_sync_callback_registered_{false}; #endif diff --git a/esphome/components/tuya/water_heater/tuya_water_heater.cpp b/esphome/components/tuya/water_heater/tuya_water_heater.cpp index e1c78530e3..4ece2b0a03 100644 --- a/esphome/components/tuya/water_heater/tuya_water_heater.cpp +++ b/esphome/components/tuya/water_heater/tuya_water_heater.cpp @@ -3,7 +3,7 @@ namespace esphome::tuya { -static const char *const TAG = "tuya.water_heater"; +ESPHOME_LOG_TAG(TAG, "tuya.water_heater"); void TuyaWaterHeater::setup() { if (this->switch_id_.has_value()) { diff --git a/esphome/components/tx20/__init__.py b/esphome/components/tx20/__init__.py index e69de29bb2..83dbfc84f6 100644 --- a/esphome/components/tx20/__init__.py +++ b/esphome/components/tx20/__init__.py @@ -0,0 +1 @@ +DOMAIN = "tx20" 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/tx20/tx20.cpp b/esphome/components/tx20/tx20.cpp index 3574bd7c2d..ce4c20ede4 100644 --- a/esphome/components/tx20/tx20.cpp +++ b/esphome/components/tx20/tx20.cpp @@ -6,7 +6,7 @@ namespace esphome::tx20 { -static const char *const TAG = "tx20"; +ESPHOME_LOG_TAG(TAG, "tx20"); static const uint8_t MAX_BUFFER_SIZE = 41; static const uint16_t TX20_MAX_TIME = MAX_BUFFER_SIZE * 1200 + 5000; static const uint16_t TX20_BIT_TIME = 1200; diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 78633bcf6a..d1ac806b5f 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass, field from logging import getLogger import math import re @@ -5,6 +6,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 +47,7 @@ from esphome.const import ( CONF_SEQUENCE, CONF_TIMEOUT, CONF_TRIGGER_ID, + CONF_TX_BUFFER_SIZE, CONF_TX_PIN, CONF_UART_ID, PLATFORM_HOST, @@ -119,6 +137,10 @@ def validate_raw_data(value): ) +# Lengths are stored as uint16_t; reject longer payloads at validation. +validate_raw_payload = cv.All(validate_raw_data, cv.Length(max=65535)) + + def validate_rx_pin(value): value = pins.internal_gpio_input_pin_schema(value) if CORE.is_esp8266 and value[CONF_NUMBER] >= 16: @@ -179,6 +201,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 +303,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 +315,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 +400,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) @@ -386,6 +449,34 @@ UART_DEVICE_SCHEMA = cv.Schema( KEY_UART_DEVICES = "uart_devices" +@dataclass +class UARTData: + # UARTs whose received bytes do not keep the timing of a serial line. + unclocked: list[ID] = field(default_factory=list) + + +def _get_data() -> UARTData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UARTData() + return CORE.data[DOMAIN] + + +def mark_unclocked(uart_id: ID) -> ID: + """Record that the bytes of uart_id do not arrive with the timing of a serial line, e.g. over TCP or USB. + + A gap between them says nothing about where a frame ends. Use it on the UART's declared id, + cv.All(cv.declare_id(MyUart), uart.mark_unclocked), so that every to_code sees it. + """ + _get_data().unclocked.append(uart_id) + return uart_id + + +def is_unclocked(uart_id: ID) -> bool: + """Return True if uart_id was passed to mark_unclocked().""" + # By name: a generated id is named only after the schemas ran. + return any(str(uart) == str(uart_id) for uart in _get_data().unclocked) + + def final_validate_device_schema( name: str, *, @@ -431,7 +522,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}" ) @@ -474,6 +566,25 @@ def final_validate_device_schema( ) +def subtree_references_uart( + node: object, uart_id: str, conf_key: str = CONF_UART_ID +) -> bool: + """Return True if any dict in the subtree has a conf_key entry naming this bus. + + For the final validation of a component that needs a UART for itself. Bare + `id:` references (a uart.write action) and lambdas are not found. + """ + if isinstance(node, dict): + return any( + (key == conf_key and str(value) == uart_id) + or subtree_references_uart(value, uart_id, conf_key) + for key, value in node.items() + ) + if isinstance(node, list): + return any(subtree_references_uart(item, uart_id, conf_key) for item in node) + return False + + async def register_uart_device(var, config): """Register a UART device, setting up all the internal values. @@ -489,7 +600,7 @@ async def register_uart_device(var, config): cv.maybe_simple_value( { cv.GenerateID(): cv.use_id(UARTComponent), - cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + cv.Required(CONF_DATA): cv.templatable(validate_raw_payload), }, key=CONF_DATA, ), @@ -498,18 +609,9 @@ async def register_uart_device(var, config): async def uart_write_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - data = config[CONF_DATA] - if isinstance(data, bytes): - data = list(data) - - if cg.is_template(data): - templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) - cg.add(var.set_data_template(templ)) - else: - # Generate static array in flash to avoid RAM copy - arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) - arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) - cg.add(var.set_data_static(arr, len(data))) + await automation.templatable_bytes( + config[CONF_DATA], args, var.set_data_template, var.set_data_static, "uart_data" + ) return var diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index e5a9fa7c7b..b134c8af9f 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -8,36 +8,15 @@ namespace esphome::uart { template class UARTWriteAction final : public Action, public Parented { - public: - void set_data_template(std::vector (*func)(Ts...)) { - // Stateless lambdas (generated by ESPHome) implicitly convert to function pointers - this->code_.func = func; - this->len_ = -1; // Sentinel value indicates template mode - } - - // Store pointer to static data in flash (no RAM copy) - void set_data_static(const uint8_t *data, size_t len) { - this->code_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->len_ >= 0) { - // Static mode: use pointer and length - this->parent_->write_array(this->code_.data, static_cast(this->len_)); + if (this->data_.is_static()) { + this->parent_->write_array_progmem(this->data_.data(), this->data_.size()); } else { - // Template mode: call function - auto val = this->code_.func(x...); - this->parent_->write_array(val); + this->parent_->write_array(this->data_.value(x...)); } } - - protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length - union Code { - std::vector (*func)(Ts...); // Function pointer (stateless lambdas) - const uint8_t *data; // Pointer to static data in flash - } code_; }; } // namespace esphome::uart diff --git a/esphome/components/uart/button/__init__.py b/esphome/components/uart/button/__init__.py index 95fe21271d..821d8264dc 100644 --- a/esphome/components/uart/button/__init__.py +++ b/esphome/components/uart/button/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import button, uart import esphome.config_validation as cv from esphome.const import CONF_DATA -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload CODEOWNERS = ["@ssieb"] @@ -17,7 +17,7 @@ CONFIG_SCHEMA = ( button.button_schema(UARTButton) .extend( { - cv.Required(CONF_DATA): validate_raw_data, + cv.Required(CONF_DATA): validate_raw_payload, } ) .extend(uart.UART_DEVICE_SCHEMA) @@ -31,6 +31,4 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data(cg.ArrayInitializer(*data))) + cg.add(var.set_data(automation.progmem_bytes("uart_data", data), len(data))) diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index 809ceaabb0..99b5ee548d 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -3,11 +3,11 @@ namespace esphome::uart { -static const char *const TAG = "uart.button"; +ESPHOME_LOG_TAG(TAG, "uart.button"); void UARTButton::press_action() { ESP_LOGD(TAG, "'%s': Sending data", this->get_name().c_str()); - this->write_array(this->data_.data(), this->data_.size()); + this->write_array_progmem(this->data_, this->data_len_); } void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 47f45d4899..903f945fd3 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -4,20 +4,21 @@ #include "esphome/components/uart/uart.h" #include "esphome/components/button/button.h" -#include - namespace esphome::uart { class UARTButton final : public button::Button, public UARTDevice, public Component { public: - void set_data(std::vector &&data) { this->data_ = std::move(data); } - void set_data(std::initializer_list data) { this->data_ = std::vector(data); } + void set_data(const uint8_t *data, uint16_t len) { + this->data_ = data; + this->data_len_ = len; + } void dump_config() override; protected: void press_action() override; - std::vector data_; + const uint8_t *data_{nullptr}; + uint16_t data_len_{0}; }; } // namespace esphome::uart diff --git a/esphome/components/uart/event/uart_event.cpp b/esphome/components/uart/event/uart_event.cpp index 02c5f2e631..22bcee9c83 100644 --- a/esphome/components/uart/event/uart_event.cpp +++ b/esphome/components/uart/event/uart_event.cpp @@ -4,7 +4,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.event"; +ESPHOME_LOG_TAG(TAG, "uart.event"); void UARTEvent::setup() {} diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 6b8eae611c..215cdded99 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -4,7 +4,7 @@ namespace esphome::uart { -static const char *const TAG = "uart_transport"; +ESPHOME_LOG_TAG(TAG, "uart_transport"); void UARTTransport::loop() { PacketTransport::loop(); diff --git a/esphome/components/uart/software_serial_rx_decoder.h b/esphome/components/uart/software_serial_rx_decoder.h new file mode 100644 index 0000000000..d80dbf1a85 --- /dev/null +++ b/esphome/components/uart/software_serial_rx_decoder.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::uart { + +/// Software serial RX decoder: on_edge() counts bits from the time between edges; +/// finalize() completes a byte whose idle high tail has no closing edge. +/// Times are in any steady tick unit. +class SoftwareSerialRxDecoder { + public: + static constexpr uint8_t RX_IDLE = 0xFF; + + /// Configure framing and buffer; drops all state. Follow with reset(). + void setup(uint32_t bit_ticks, uint8_t data_bits, bool parity, uint8_t stop_bits, uint8_t *buffer, + size_t buffer_size) { + this->bit_ticks_ = bit_ticks; + this->data_bits_ = data_bits; + this->stop_bit_ = data_bits + (parity ? 1 : 0); + this->max_run_ticks_ = bit_ticks * (this->stop_bit_ + stop_bits + 2); + this->buffer_ = buffer; + this->buffer_size_ = buffer_size; + this->in_pos_ = 0; + this->out_pos_ = 0; + this->reset(0, true); + } + + /// Forget any partial frame; `level` is the current line level. + void reset(uint32_t now, bool level) { + this->bit_ = RX_IDLE; + this->cur_byte_ = 0; + this->last_level_ = level; + this->last_edge_ = now; + } + + /// ISR. True to wake the loop: a byte was pushed, or the line went high in an + /// open frame (a data 1 and the idle tail look alike) so finalize() may be needed. + bool ESPHOME_ALWAYS_INLINE on_edge(uint32_t now, bool level) { + const bool last_level = this->last_level_; + // Collapsed edge + if (level == last_level) + return false; + // LX106 has no divider, so count + uint32_t delta = now - this->last_edge_; + if (delta > this->max_run_ticks_) + delta = this->max_run_ticks_; + delta += this->bit_ticks_ / 2; + uint32_t bits = 0; + while (delta >= this->bit_ticks_) { + delta -= this->bit_ticks_; + bits++; + } + const bool pushed = this->consume_run_(bits, last_level); + this->last_edge_ = now; + this->last_level_ = level; + return pushed || (level && this->bit_ != RX_IDLE); + } + + bool pending() const { return this->bit_ != RX_IDLE && this->last_level_; } + + bool finalize_due(uint32_t now) const { + const uint8_t bit = this->bit_; + if (bit == RX_IDLE) + return false; + return now - this->last_edge_ >= this->tail_ticks_(bit); + } + + /// Call with the ISR masked. + void finalize(uint32_t now) { + if (!this->last_level_ || !this->finalize_due(now)) + return; + // Remaining bits are all ones + const uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + if (bit < this->data_bits_) + cur |= ((1U << this->data_bits_) - 1) & ~((1U << bit) - 1); + this->push_byte(cur); + this->bit_ = RX_IDLE; + } + + /// Drops the byte when full; holds buffer_size - 1 bytes. + bool ESPHOME_ALWAYS_INLINE push_byte(uint8_t data) { + size_t in = this->in_pos_; + size_t next = in + 1; + if (next == this->buffer_size_) + next = 0; + if (next == this->out_pos_) + return false; + this->buffer_[in] = data; + this->in_pos_ = next; + return true; + } + + uint8_t *buffer() const { return this->buffer_; } + size_t buffer_size() const { return this->buffer_size_; } + + size_t available() const { + // Read volatile in_pos_ once to avoid TOCTOU race with ISR. + size_t in = this->in_pos_; + if (in >= this->out_pos_) + return in - this->out_pos_; + return this->buffer_size_ - this->out_pos_ + in; + } + uint8_t peek_byte() const { + if (this->in_pos_ == this->out_pos_) + return 0; + return this->buffer_[this->out_pos_]; + } + uint8_t read_byte() { + if (this->in_pos_ == this->out_pos_) + return 0; + uint8_t data = this->buffer_[this->out_pos_]; + size_t next = this->out_pos_ + 1; + this->out_pos_ = next == this->buffer_size_ ? 0 : next; + return data; + } + + protected: + uint32_t tail_ticks_(uint8_t bit) const { + return (this->stop_bit_ + 1 - bit) * this->bit_ticks_ + this->bit_ticks_ / 2; + } + + bool ESPHOME_ALWAYS_INLINE consume_run_(uint32_t bits, bool level) { + uint8_t bit = this->bit_; + uint8_t cur = this->cur_byte_; + bool pushed = false; + while (bits > 0) { + if (bit == RX_IDLE) { + if (level) + break; + // Start bit + bit = 0; + cur = 0; + bits--; + } else if (bit < this->data_bits_) { + uint8_t n = this->data_bits_ - bit; + if (n > bits) + n = bits; + if (level) + cur |= ((1U << n) - 1) << bit; + bit += n; + bits -= n; + } else if (bit < this->stop_bit_) { + // Parity, not checked + bit++; + bits--; + } else { + // Stop bit; low is a framing error + if (level) + pushed = this->push_byte(cur); + bit = RX_IDLE; + break; + } + } + this->bit_ = bit; + this->cur_byte_ = cur; + return pushed; + } + + // Members ordered largest to smallest to minimize padding + uint32_t bit_ticks_{0}; + uint32_t max_run_ticks_{0}; + volatile uint32_t last_edge_{0}; + uint8_t *buffer_{nullptr}; + size_t buffer_size_{0}; + volatile size_t in_pos_{0}; + size_t out_pos_{0}; // reader only + /// Next frame bit (data, parity, then stop at stop_bit_) or RX_IDLE. + volatile uint8_t bit_{RX_IDLE}; + volatile uint8_t cur_byte_{0}; + volatile bool last_level_{true}; + uint8_t data_bits_{8}; + uint8_t stop_bit_{8}; +}; + +} // namespace esphome::uart diff --git a/esphome/components/uart/switch/__init__.py b/esphome/components/uart/switch/__init__.py index 290bbed5d3..3288ceeee2 100644 --- a/esphome/components/uart/switch/__init__.py +++ b/esphome/components/uart/switch/__init__.py @@ -1,10 +1,10 @@ +from esphome import automation import esphome.codegen as cg from esphome.components import switch, uart import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_SEND_EVERY -from esphome.core import HexInt -from .. import uart_ns, validate_raw_data +from .. import uart_ns, validate_raw_payload DEPENDENCIES = ["uart"] @@ -18,11 +18,11 @@ CONFIG_SCHEMA = ( .extend( { cv.Required(CONF_DATA): cv.Any( - validate_raw_data, + validate_raw_payload, cv.Schema( { - cv.Optional(CONF_TURN_OFF): validate_raw_data, - cv.Optional(CONF_TURN_ON): validate_raw_data, + cv.Optional(CONF_TURN_OFF): validate_raw_payload, + cv.Optional(CONF_TURN_ON): validate_raw_payload, } ), ), @@ -40,20 +40,16 @@ async def to_code(config): await uart.register_uart_device(var, config) data = config[CONF_DATA] - if isinstance(data, dict): - if data_on := data.get(CONF_TURN_ON): - if isinstance(data_on, bytes): - data_on = [HexInt(x) for x in data_on] - cg.add(var.set_data_on(cg.ArrayInitializer(*data_on))) - if data_off := data.get(CONF_TURN_OFF): - if isinstance(data_off, bytes): - data_off = [HexInt(x) for x in data_off] - cg.add(var.set_data_off(cg.ArrayInitializer(*data_off))) - else: - data = config[CONF_DATA] - if isinstance(data, bytes): - data = [HexInt(x) for x in data] - cg.add(var.set_data_on(cg.ArrayInitializer(*data))) + single_state = not isinstance(data, dict) + if single_state: + data = {CONF_TURN_ON: data} + for key, setter in ( + (CONF_TURN_ON, var.set_data_on), + (CONF_TURN_OFF, var.set_data_off), + ): + if payload := data.get(key): + cg.add(setter(automation.progmem_bytes("uart_data", payload), len(payload))) + if single_state: cg.add(var.set_single_state(True)) if CONF_SEND_EVERY in config: cg.add(var.set_send_every(config[CONF_SEND_EVERY])) diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 642bd19772..1af497ffcf 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -4,7 +4,7 @@ namespace esphome::uart { -static const char *const TAG = "uart.switch"; +ESPHOME_LOG_TAG(TAG, "uart.switch"); void UARTSwitch::loop() { if (this->send_every_) { @@ -17,13 +17,13 @@ void UARTSwitch::loop() { } void UARTSwitch::write_command_(bool state) { - if (state && !this->data_on_.empty()) { + if (state && this->data_on_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending on data", this->get_name().c_str()); - this->write_array(this->data_on_.data(), this->data_on_.size()); + this->write_array_progmem(this->data_on_, this->data_on_len_); } - if (!state && !this->data_off_.empty()) { + if (!state && this->data_off_len_ != 0) { ESP_LOGD(TAG, "'%s': Sending off data", this->get_name().c_str()); - this->write_array(this->data_off_.data(), this->data_off_.size()); + this->write_array_progmem(this->data_off_, this->data_off_len_); } } diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index c924c7d4e5..96d8833d7a 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -5,7 +5,6 @@ #include "esphome/components/switch/switch.h" #include -#include namespace esphome::uart { @@ -13,10 +12,14 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo public: void loop() override; - void set_data_on(std::vector &&data) { this->data_on_ = std::move(data); } - void set_data_on(std::initializer_list data) { this->data_on_ = std::vector(data); } - void set_data_off(std::vector &&data) { this->data_off_ = std::move(data); } - void set_data_off(std::initializer_list data) { this->data_off_ = std::vector(data); } + void set_data_on(const uint8_t *data, uint16_t len) { + this->data_on_ = data; + this->data_on_len_ = len; + } + void set_data_off(const uint8_t *data, uint16_t len) { + this->data_off_ = data; + this->data_off_len_ = len; + } void set_send_every(uint32_t send_every) { this->send_every_ = send_every; } void set_single_state(bool single) { this->single_state_ = single; } @@ -25,8 +28,10 @@ class UARTSwitch final : public switch_::Switch, public UARTDevice, public Compo protected: void write_command_(bool state); void write_state(bool state) override; - std::vector data_on_; - std::vector data_off_; + const uint8_t *data_on_{nullptr}; + const uint8_t *data_off_{nullptr}; + uint16_t data_on_len_{0}; + uint16_t data_off_len_{0}; bool single_state_{false}; uint32_t send_every_; uint32_t last_transmission_; diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index 337fa352c1..56b8e6401e 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -8,10 +8,10 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); -// UART parity strings indexed by UARTParityOptions enum (0-2): NONE, EVEN, ODD -PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "UNKNOWN"); +// UART parity strings indexed by UARTParityOptions enum (0-4): NONE, EVEN, ODD, MARK, SPACE +PROGMEM_STRING_TABLE(UARTParityStrings, "NONE", "EVEN", "ODD", "MARK", "SPACE", "UNKNOWN"); void UARTDevice::check_uart_settings(uint32_t baud_rate, uint8_t stop_bits, UARTParityOptions parity, uint8_t data_bits) { diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index eda5b72ea8..7a34a20583 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -25,6 +25,7 @@ class UARTDevice { size_t get_rx_timeout() { return this->parent_->get_rx_timeout(); } void write_array(const uint8_t *data, size_t len) { this->parent_->write_array(data, len); } + void write_array_progmem(const uint8_t *data, size_t len) { this->parent_->write_array_progmem(data, len); } void write_array(const std::vector &data) { this->parent_->write_array(data); } template void write_array(const std::array &data) { this->parent_->write_array(data.data(), data.size()); @@ -37,9 +38,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.cpp b/esphome/components/uart/uart_component.cpp index 762e56c399..e77e37cd08 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -1,8 +1,14 @@ #include "uart_component.h" +#include "esphome/core/application.h" + +#include namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); + +// Keeps the pacing product in 32 bits up to about 10 Mbaud. +static constexpr uint32_t MAX_PACE_SPAN_MS = 4000; bool UARTComponent::check_read_timeout_(size_t len) { if (this->available() >= len) @@ -19,6 +25,19 @@ bool UARTComponent::check_read_timeout_(size_t len) { return true; } +size_t UARTComponent::paced_write_room(uint32_t last_write_ms) { + size_t room = this->available_for_write(); + if (room != SIZE_MAX) { + return room; + } + // A pass woken early writes little. + uint32_t span = + std::min({App.get_loop_component_start_time() - last_write_ms, App.get_loop_interval(), MAX_PACE_SPAN_MS}); + // 10 bits per byte on the line. + uint32_t paced = this->baud_rate_ / 10 * span / 1000; + return std::max(1, paced); +} + void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { uint8_t bytelength = this->data_bits_ + this->stop_bits_ + 1; if (this->parity_ != UARTParityOptions::UART_CONFIG_PARITY_NONE) @@ -27,4 +46,17 @@ void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { this->set_rx_full_threshold(val); } +#ifdef USE_ESP8266 +void UARTComponent::write_array_progmem(const uint8_t *data, size_t len) { + uint8_t buf[32]; + while (len > 0) { + const size_t chunk = std::min(len, sizeof(buf)); + progmem_memcpy(buf, data, chunk); + this->write_array(buf, chunk); + data += chunk; + len -= chunk; + } +} +#endif + } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 3e52531791..14709d4490 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" @@ -17,6 +18,9 @@ enum UARTParityOptions { UART_CONFIG_PARITY_NONE, UART_CONFIG_PARITY_EVEN, UART_CONFIG_PARITY_ODD, + // Only USB serial adapters (usb_uart) support these + UART_CONFIG_PARITY_MARK, + UART_CONFIG_PARITY_SPACE, }; #ifdef USE_UART_DEBUGGER @@ -61,6 +65,13 @@ class UARTComponent { // @param len Length of the array. virtual void write_array(const uint8_t *data, size_t len) = 0; + // Writes bytes that may live in PROGMEM; ESP8266 cannot read flash bytewise, so it copies chunks. +#ifdef USE_ESP8266 + void write_array_progmem(const uint8_t *data, size_t len); +#else + void write_array_progmem(const uint8_t *data, size_t len) { this->write_array(data, len); } +#endif + // Reads a single byte from the UART bus. // @param data Pointer to the byte where the read data will be stored. // @return True if a byte was successfully read, false otherwise. @@ -81,6 +92,14 @@ 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; } + + // Bytes a write can take now. Where available_for_write() cannot tell (SIZE_MAX), paces to the line time since + // last_write_ms, a loop start time: at most one loop interval and 4 s, at least one byte. + size_t paced_write_room(uint32_t last_write_ms); + // 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 +231,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..0819a1bfb0 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" @@ -14,7 +15,13 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); +// Edge decoder up to this baud rate; at 38400 WiFi interrupt latency corrupts bytes. +static constexpr uint32_t SW_SERIAL_EDGE_MODE_MAX_BAUD = 19200; +// Edge decoder time base: 1/16 us from the 1 MHz system timer, which keeps its rate during a +// CpuFrequencyBoost; the cycle counter does not. +static constexpr uint32_t RX_TICKS_PER_SECOND = 16000000; +__attribute__((always_inline)) static inline uint32_t rx_now() { return micros() << 4; } bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) uint32_t ESP8266UartComponent::get_config() { @@ -157,6 +164,11 @@ void ESP8266UartComponent::dump_config() { "\n Wake on data RX: ENABLED" #endif ); + if (this->rx_pin_ != nullptr) { + ESP_LOGCONFIG(TAG, " RX decoder: %s", + this->baud_rate_ <= SW_SERIAL_EDGE_MODE_MAX_BAUD ? LOG_STR_LITERAL("edge") + : LOG_STR_LITERAL("start bit sampler")); + } } this->check_logger_conflict(); } @@ -220,6 +232,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) { @@ -232,45 +250,72 @@ UARTFlushResult ESP8266UartComponent::flush() { void ESP8266SoftwareSerial::setup(InternalGPIOPin *tx_pin, InternalGPIOPin *rx_pin, uint32_t baud_rate, uint8_t stop_bits, uint32_t data_bits, UARTParityOptions parity, size_t rx_buffer_size) { + // load_settings() re-enters here + if (this->gpio_rx_pin_ != nullptr) + this->gpio_rx_pin_->detach_interrupt(); this->bit_time_ = F_CPU / baud_rate; - this->rx_buffer_size_ = rx_buffer_size; this->stop_bits_ = stop_bits; this->data_bits_ = data_bits; this->parity_ = parity; + // to_isr() allocates, so call it only for a new or changed pin if (tx_pin != nullptr) { + if (tx_pin != this->gpio_tx_pin_) + this->tx_pin_ = tx_pin->to_isr(); gpio_tx_pin_ = tx_pin; gpio_tx_pin_->setup(); - tx_pin_ = gpio_tx_pin_->to_isr(); tx_pin_.digital_write(true); } if (rx_pin != nullptr) { + if (rx_pin != this->gpio_rx_pin_) + this->rx_pin_ = rx_pin->to_isr(); gpio_rx_pin_ = rx_pin; gpio_rx_pin_->setup(); - rx_pin_ = gpio_rx_pin_->to_isr(); - rx_buffer_ = new uint8_t[this->rx_buffer_size_]; // NOLINT - gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + uint8_t *buffer = this->rx_.buffer(); + if (this->rx_.buffer_size() != rx_buffer_size) { + delete[] buffer; // NOLINT + buffer = new uint8_t[rx_buffer_size]; // NOLINT + } + this->rx_.setup((RX_TICKS_PER_SECOND + baud_rate / 2) / baud_rate, data_bits, parity != UART_CONFIG_PARITY_NONE, + stop_bits, buffer, rx_buffer_size); + this->rx_.reset(rx_now(), this->rx_pin_.digital_read()); + if (baud_rate <= SW_SERIAL_EDGE_MODE_MAX_BAUD) { + gpio_rx_pin_->attach_interrupt(ESP8266SoftwareSerial::gpio_intr_edge, this, gpio::INTERRUPT_ANY_EDGE); + } else { + 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_; + arg->rx_.push_byte(rec); // Clear RX pin so that the interrupt doesn't re-trigger right away again. arg->rx_pin_.clear_interrupt(); #ifdef USE_UART_WAKE_LOOP_ON_RX @@ -280,6 +325,27 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) { wake_loop_isrsafe(); #endif } +void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr_edge(ESP8266SoftwareSerial *arg) { + const uint32_t now = rx_now(); + const bool level = arg->rx_pin_.digital_read(); + [[maybe_unused]] const bool wake = arg->rx_.on_edge(now, level); +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (wake) + wake_loop_isrsafe(); +#endif +} +void ESP8266SoftwareSerial::rx_finalize_pending_() { + if (!this->rx_.finalize_due(rx_now())) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Tail not elapsed; wake again only once drained + if (this->rx_.available() == 0) + wake_loop_threadsafe(); +#endif + return; + } + InterruptLock lock; + this->rx_.finalize(rx_now()); +} void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) { if (this->gpio_tx_pin_ == nullptr) { ESP_LOGE(TAG, "UART doesn't have TX pins set!"); @@ -296,61 +362,54 @@ 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_) - return 0; - uint8_t data = this->rx_buffer_[this->rx_out_pos_]; - this->rx_out_pos_ = (this->rx_out_pos_ + 1) % this->rx_buffer_size_; - return data; + this->rx_sync_(); + return this->rx_.read_byte(); } uint8_t ESP8266SoftwareSerial::peek_byte() { - if (this->rx_in_pos_ == this->rx_out_pos_) - return 0; - return this->rx_buffer_[this->rx_out_pos_]; + this->rx_sync_(); + return this->rx_.peek_byte(); } void ESP8266SoftwareSerial::flush() { // Flush is a NO-OP with software serial, all bytes are written immediately. } size_t ESP8266SoftwareSerial::available() { - // Read volatile rx_in_pos_ once to avoid TOCTOU race with ISR. - // When in >= out, data is contiguous: [out..in). - // When in < out, data wraps: [out..buf_size) + [0..in). - size_t in = this->rx_in_pos_; - if (in >= this->rx_out_pos_) - return in - this->rx_out_pos_; - return this->rx_buffer_size_ - this->rx_out_pos_ + in; + this->rx_sync_(); + return this->rx_.available(); } } // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 469885b6b6..3d4a2ba85b 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -7,6 +7,7 @@ #include "esphome/core/component.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "software_serial_rx_decoder.h" #include "uart_component.h" namespace esphome::uart { @@ -26,24 +27,30 @@ class ESP8266SoftwareSerial { size_t available(); protected: + /// Above SW_SERIAL_EDGE_MODE_MAX_BAUD; reads the whole byte in the ISR. static void gpio_intr(ESP8266SoftwareSerial *arg); + static void gpio_intr_edge(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); + void rx_finalize_pending_(); + void ESPHOME_ALWAYS_INLINE rx_sync_() { + if (this->rx_.pending()) + this->rx_finalize_pending_(); + } + + // Members ordered largest to smallest to minimize padding uint32_t bit_time_{0}; - uint8_t *rx_buffer_{nullptr}; - size_t rx_buffer_size_; - volatile size_t rx_in_pos_{0}; - size_t rx_out_pos_{0}; - uint8_t stop_bits_; - uint8_t data_bits_; - UARTParityOptions parity_; InternalGPIOPin *gpio_tx_pin_{nullptr}; ISRInternalGPIOPin tx_pin_; InternalGPIOPin *gpio_rx_pin_{nullptr}; ISRInternalGPIOPin rx_pin_; + SoftwareSerialRxDecoder rx_; + UARTParityOptions parity_; + uint8_t stop_bits_; + uint8_t data_bits_; }; class ESP8266UartComponent final : public UARTComponent, public Component { @@ -58,6 +65,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 e5d5fbc983..cccb7289fb 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 @@ -21,7 +23,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); /// Check if a pin number matches one of the default UART0 GPIO pins. /// These pins may have residual IOMUX state from the ROM bootloader that @@ -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)); @@ -210,6 +238,10 @@ void IDFUARTComponent::load_settings(bool dump_config) { this->mark_failed(); return; } +#ifdef USE_GPIO_HOLD + // Release held pins so the UART peripheral can drive them + this->set_pins_hold_(false); +#endif #ifdef USE_UART_WAKE_LOOP_ON_RX // Register ISR callback to wake the main loop when UART data arrives. @@ -329,6 +361,9 @@ void IDFUARTComponent::dump_config() { " RX Timeout: %u", this->rx_buffer_size_, this->rx_full_threshold_, this->rx_timeout_); } + if (this->tx_buffer_size_ > 0) { + ESP_LOGCONFIG(TAG, " TX Buffer Size: %zu", this->tx_buffer_size_); + } if (this->flush_timeout_ms_ > 0) { ESP_LOGCONFIG(TAG, " Flush Timeout: %" PRIu32 " ms", this->flush_timeout_ms_); } @@ -336,12 +371,14 @@ void IDFUARTComponent::dump_config() { " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" - " Stop bits: %u" + " Stop bits: %u\n" + " Clock Source: %s" #ifdef USE_UART_WAKE_LOOP_ON_RX "\n Wake on data RX: ENABLED" #endif , - this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); + this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_, + LOG_STR_ARG(clock_source_to_str(static_cast(this->clock_source_)))); this->check_logger_conflict(); } @@ -367,6 +404,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) { @@ -469,7 +518,20 @@ void IDFUARTComponent::on_shutdown() { if (err != ESP_OK) { ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); } +#ifdef USE_GPIO_HOLD + // Hold the pins again so they keep their state through the reset + this->set_pins_hold_(true); +#endif } +#ifdef USE_GPIO_HOLD +void IDFUARTComponent::set_pins_hold_(bool hold) { + for (auto *pin : {this->tx_pin_, this->rx_pin_, this->flow_control_pin_}) { + if (pin != nullptr) + pin->set_hold(hold); + } +} +#endif + } // namespace esphome::uart #endif // USE_ESP32 diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index b591fbe968..260117c429 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -33,10 +33,17 @@ class IDFUARTComponent final : public UARTComponent, public Component { bool read_array(uint8_t *data, size_t len) override; size_t available() override; + size_t available_for_write() override; UARTFlushResult flush() override; void set_flush_timeout(uint32_t flush_timeout_ms) override { this->flush_timeout_ms_ = flush_timeout_ms; } + /// TX ring buffer size for the driver; 0 leaves TX unbuffered so write_array() blocks until + /// the FIFO has taken everything. + void set_tx_buffer_size(size_t tx_buffer_size) { this->tx_buffer_size_ = tx_buffer_size; } + + void set_clock_source(uart_sclk_t clock_source) { this->clock_source_ = static_cast(clock_source); } + uint8_t get_hw_serial_number() { return this->uart_num_; } /// Discard everything received so far: the peek cache and the driver's RX buffer. @@ -83,6 +90,9 @@ class IDFUARTComponent final : public UARTComponent, public Component { protected: void check_logger_conflict() override; +#ifdef USE_GPIO_HOLD + void set_pins_hold_(bool hold); +#endif uint32_t line_inversion_mask_(); // Re-applies what uart_param_config() resets: inversion, RX threshold/timeout, mode. esp_err_t apply_line_settings_(); @@ -105,7 +115,9 @@ class IDFUARTComponent final : public UARTComponent, public Component { bool has_peek_{false}; uint8_t peek_byte_{0}; - uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + uint8_t clock_source_{UART_SCLK_DEFAULT}; ///< uart_sclk_t stored in a byte; the IDF values are all small. + uint32_t flush_timeout_ms_{0}; ///< 0 means wait indefinitely (portMAX_DELAY). + size_t tx_buffer_size_{0}; #ifdef USE_UART_WAKE_LOOP_ON_RX // ISR callback for UART RX data notification — wakes the main loop directly. diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index 63b5631564..7fb704536d 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -98,7 +98,7 @@ speed_t get_baud(int baud) { namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); HostUartComponent::~HostUartComponent() { if (this->file_descriptor_ != -1) { @@ -158,6 +158,8 @@ void HostUartComponent::setup() { options.c_cflag |= PARENB; options.c_cflag |= PARODD; break; + default: + break; }; // Set stop bits if (this->stop_bits_ == 2) { diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 4eacd980db..802a88ba80 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 @@ -16,7 +18,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); static const char *const UART_TYPE[] = { "hardware", @@ -36,6 +38,8 @@ uint16_t LibreTinyUARTComponent::get_config() { case UART_CONFIG_PARITY_ODD: config |= SERIAL_PARITY_ODD; break; + default: + break; } config |= (this->data_bits_ - 4) << 8; @@ -118,6 +122,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/uart_component_rp2.cpp b/esphome/components/uart/uart_component_rp2.cpp index ffb9bc0f2d..07245bc230 100644 --- a/esphome/components/uart/uart_component_rp2.cpp +++ b/esphome/components/uart/uart_component_rp2.cpp @@ -13,7 +13,7 @@ namespace esphome::uart { -static const char *const TAG = "uart"; +ESPHOME_LOG_TAG(TAG, "uart"); uint16_t RP2UartComponent::get_config() { uint16_t config = 0; diff --git a/esphome/components/uart/uart_debugger.cpp b/esphome/components/uart/uart_debugger.cpp index e0be599d8d..53c6c0406a 100644 --- a/esphome/components/uart/uart_debugger.cpp +++ b/esphome/components/uart/uart_debugger.cpp @@ -8,7 +8,7 @@ namespace esphome::uart { -static const char *const TAG = "uart_debug"; +ESPHOME_LOG_TAG(TAG, "uart_debug"); UARTDebugger::UARTDebugger(UARTComponent *parent) { parent->add_debug_callback([this](UARTDirection direction, uint8_t byte) { diff --git a/esphome/components/uart_mux/__init__.py b/esphome/components/uart_mux/__init__.py index 6c479f0cdd..f97a7b2cb3 100644 --- a/esphome/components/uart_mux/__init__.py +++ b/esphome/components/uart_mux/__init__.py @@ -20,9 +20,6 @@ ROUTE_LOCAL = "local" uart_mux_ns = cg.esphome_ns.namespace("uart_mux") UARTMux = uart_mux_ns.class_("UARTMux", uart.UARTComponent, cg.Component) -SelectLocalAction = uart_mux_ns.class_("SelectLocalAction", automation.Action) -SelectBridgeAction = uart_mux_ns.class_("SelectBridgeAction", automation.Action) -IsLocalCondition = uart_mux_ns.class_("IsLocalCondition", automation.Condition) CONFIG_SCHEMA = cv.All( cv.Schema( @@ -70,15 +67,16 @@ UART_MUX_ACTION_SCHEMA = automation.maybe_simple_id( ) -automation.register_simple_action( - "uart_mux.select_local", SelectLocalAction, UART_MUX_ACTION_SCHEMA, synchronous=True -) -automation.register_simple_action( - "uart_mux.select_bridge", - SelectBridgeAction, +automation.register_apply_action( + "uart_mux.select_local", UART_MUX_ACTION_SCHEMA, - synchronous=True, + automation.ApplyCall("select_local()"), ) -automation.register_simple_condition( - "uart_mux.is_local", IsLocalCondition, UART_MUX_ACTION_SCHEMA +automation.register_apply_action( + "uart_mux.select_bridge", + UART_MUX_ACTION_SCHEMA, + automation.ApplyCall("select_bridge()"), +) +automation.register_apply_condition( + "uart_mux.is_local", UART_MUX_ACTION_SCHEMA, "is_local()" ) diff --git a/esphome/components/uart_mux/uart_mux.cpp b/esphome/components/uart_mux/uart_mux.cpp index 953e81d533..11057ac078 100644 --- a/esphome/components/uart_mux/uart_mux.cpp +++ b/esphome/components/uart_mux/uart_mux.cpp @@ -4,7 +4,7 @@ namespace esphome::uart_mux { -static const char *const TAG = "uart_mux"; +ESPHOME_LOG_TAG(TAG, "uart_mux"); void UARTMux::setup() { // A failed UART never assigned its port; nothing behind the mux can work. diff --git a/esphome/components/uart_mux/uart_mux.h b/esphome/components/uart_mux/uart_mux.h index 8e32a16643..5b9815a3ca 100644 --- a/esphome/components/uart_mux/uart_mux.h +++ b/esphome/components/uart_mux/uart_mux.h @@ -3,7 +3,6 @@ #include "esphome/components/uart/uart_component.h" #include "esphome/components/uart/uart_component_esp_idf.h" #include "esphome/components/cdc_acm_uart/bridge/cdc_acm_uart_bridge.h" -#include "esphome/core/automation.h" #include "esphome/core/component.h" namespace esphome::uart_mux { @@ -77,32 +76,5 @@ class UARTMux final : public uart::UARTComponent, public Component { bool load_settings_warned_{false}; }; -template class SelectLocalAction final : public Action { - public: - explicit SelectLocalAction(UARTMux *parent) : parent_(parent) {} - void play(const Ts &...) override { this->parent_->select_local(); } - - protected: - UARTMux *parent_; -}; - -template class SelectBridgeAction final : public Action { - public: - explicit SelectBridgeAction(UARTMux *parent) : parent_(parent) {} - void play(const Ts &...) override { this->parent_->select_bridge(); } - - protected: - UARTMux *parent_; -}; - -template class IsLocalCondition final : public Condition { - public: - explicit IsLocalCondition(UARTMux *parent) : parent_(parent) {} - bool check(const Ts &...) override { return this->parent_->is_local(); } - - protected: - UARTMux *parent_; -}; - } // namespace esphome::uart_mux #endif diff --git a/esphome/components/uart_tcp/__init__.py b/esphome/components/uart_tcp/__init__.py new file mode 100644 index 0000000000..9d62d48d7c --- /dev/null +++ b/esphome/components/uart_tcp/__init__.py @@ -0,0 +1,150 @@ +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, +) +from esphome.components.tcp_uart import DOMAIN as TCP_UART_DOMAIN +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 _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 uart.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_DOMAIN, []) + ): + 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..d634ac579f --- /dev/null +++ b/esphome/components/uart_tcp/uart_tcp.cpp @@ -0,0 +1,141 @@ +#include "uart_tcp.h" + +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome::uart_tcp { + +ESPHOME_LOG_TAG(TAG, "uart_tcp"); + +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_->paced_write_room(this->last_write_ms_); + 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..5957870e7f 100644 --- a/esphome/components/udp/__init__.py +++ b/esphome/components/udp/__init__.py @@ -1,9 +1,11 @@ +from ipaddress import IPv4Address, IPv6Address from typing import Any from esphome import automation from esphome.automation import Trigger import esphome.codegen as cg from esphome.components.const import CONF_ON_RECEIVE +from esphome.components.network import DOMAIN as NETWORK_DOMAIN from esphome.components.packet_transport import ( CONF_BINARY_SENSORS, CONF_ENCRYPTION, @@ -13,14 +15,22 @@ from esphome.components.packet_transport import ( CONF_SENSORS, ) import esphome.config_validation as cv -from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID +from esphome.const import ( + CONF_DATA, + CONF_ENABLE_IPV6, + CONF_ID, + CONF_PORT, + CONF_TRIGGER_ID, +) from esphome.core import ID from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["network"] AUTO_LOAD = ["socket"] +DOMAIN = "udp" MULTI_CONF = True udp_ns = cg.esphome_ns.namespace("udp") @@ -62,13 +72,80 @@ RELOCATED = { } -def _consume_udp_sockets(config: ConfigType) -> ConfigType: - """Register socket needs for UDP component.""" +def _no_scope_id(addr: IPv4Address | IPv6Address) -> IPv4Address | IPv6Address: + """Python accepts a scope id, which neither inet_pton nor lwIP's inet6_aton takes.""" + if isinstance(addr, IPv6Address) and addr.scope_id is not None: + raise cv.Invalid( + f"{addr} carries a scope id, which the device cannot parse; " + "give the address without the '%' part" + ) + return addr + + +def _address(value: Any) -> IPv4Address | IPv6Address: + addr = _no_scope_id(cv.ipaddress(value)) + # A link-local destination needs a scope id, which the device cannot pick on every platform + if isinstance(addr, IPv6Address) and addr.is_link_local: + raise cv.Invalid( + f"{addr} is link-local; use a multicast, unique local or global address" + ) + return addr + + +def _listen_address(value: Any) -> IPv4Address | IPv6Address: + """Accept an IPv4 multicast/broadcast or an IPv6 multicast address, with a family-specific error.""" + if ":" not in str(value): + return cv.ipv4address_multi_broadcast(value) + addr = _no_scope_id(cv.ipv6address(value)) + if not addr.is_multicast: + raise cv.Invalid(f"{value} is not an IPv6 multicast address") + return addr + + +def _final_validate(config: ConfigType) -> None: from esphome.components import socket - # UDP uses up to 2 sockets: 1 broadcast + 1 listen - # Whether each is used depends on code generation, so register worst case - socket.consume_sockets(2, "udp", socket.SocketType.UDP)(config) + full_config = fv.full_config.get() + ipv6_enabled = full_config.get(NETWORK_DOMAIN, {}).get(CONF_ENABLE_IPV6, False) + raw_lwip = ( + full_config.get(socket.DOMAIN, {}).get(socket.CONF_IMPLEMENTATION) + == socket.IMPLEMENTATION_LWIP_TCP + ) + for key, addresses in ( + (CONF_ADDRESSES, config[CONF_ADDRESSES]), + (CONF_LISTEN_ADDRESS, [config[CONF_LISTEN_ADDRESS]]), + ): + for addr in addresses: + if addr.version != 6: + continue + if raw_lwip: + raise cv.Invalid( + f"IPv6 address '{addr}' is not supported on this platform", [key] + ) + if not ipv6_enabled: + raise cv.Invalid( + f"IPv6 address '{addr}' requires 'enable_ipv6: true' in the 'network' component", + [key], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +def _ipv6_addresses(config: ConfigType) -> list[IPv6Address]: + return [addr for addr in config[CONF_ADDRESSES] if addr.version == 6] + + +def _has_ipv4_address(config: ConfigType) -> bool: + return any(addr.version == 4 for addr in config[CONF_ADDRESSES]) + + +def _consume_udp_sockets(config: ConfigType) -> ConfigType: + """Reserve the listen socket plus one send socket per address family in use.""" + from esphome.components import socket + + count = 1 + _has_ipv4_address(config) + bool(_ipv6_addresses(config)) + socket.consume_sockets(count, "udp", socket.SocketType.UDP)(config) return config @@ -87,9 +164,9 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional( CONF_LISTEN_ADDRESS, default="255.255.255.255" - ): cv.ipv4address_multi_broadcast, + ): _listen_address, cv.Optional(CONF_ADDRESSES, default=["255.255.255.255"]): cv.ensure_list( - cv.ipv4address, + _address ), cv.Optional(CONF_ON_RECEIVE): automation.validate_automation( { @@ -124,7 +201,20 @@ 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]])) + if ipv6_addresses := _ipv6_addresses(config): + cg.add_define("USE_UDP_IPV6_SEND") + cg.add(var.set_ipv6_send(any(addr.is_multicast for addr in ipv6_addresses))) + if not _has_ipv4_address(config): + cg.add(var.set_ipv4_send(False)) + # 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) @@ -176,16 +266,7 @@ async def udp_write_to_code( udp_var = await cg.get_variable(config[CONF_ID]) await cg.register_parented(var, udp_var) cg.add(udp_var.set_should_broadcast()) - 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, "udp_data" + ) return var diff --git a/esphome/components/udp/automation.h b/esphome/components/udp/automation.h index 9b3ba7e0dd..0936aa3ab5 100644 --- a/esphome/components/udp/automation.h +++ b/esphome/components/udp/automation.h @@ -4,39 +4,19 @@ #ifdef USE_NETWORK #include "esphome/core/automation.h" -#include - namespace esphome::udp { -template class UDPWriteAction 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 - } +/// Constant payloads up to this size are sent from the stack on ESP8266. +static constexpr size_t STACK_PAYLOAD_SIZE = 128; - void set_data_static(const uint8_t *data, size_t len) { - this->data_.data = data; - this->len_ = len; // Length >= 0 indicates static mode - } +template class UDPWriteAction final : public Action, public Parented { + TEMPLATABLE_BYTES(data) void play(const Ts &...x) override { - if (this->len_ >= 0) { - // Static mode: pass pointer directly to send_packet(const uint8_t *, size_t) - this->parent_->send_packet(this->data_.data, static_cast(this->len_)); - } else { - // Template mode: call function and pass vector to send_packet(const std::vector &) - auto val = this->data_.func(x...); - this->parent_->send_packet(val); - } + // One sendto per packet, so a failed allocation never sends a partial packet + this->data_.template visit( + [this](const uint8_t *data, size_t len) { this->parent_->send_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_; }; } // namespace esphome::udp diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index 40ce46d74e..2e49b676ce 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -5,7 +5,7 @@ namespace esphome::udp { -static const char *const TAG = "udp_transport"; +ESPHOME_LOG_TAG(TAG, "udp_transport"); bool UDPTransport::should_send() { return network::is_connected(); } void UDPTransport::setup() { diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 858516c746..e143f93a4b 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -7,38 +7,96 @@ namespace esphome::udp { -static const char *const TAG = "udp"; +ESPHOME_LOG_TAG(TAG, "udp"); +static constexpr uint32_t NETWORK_POLL_ID = 0; +static constexpr uint32_t NETWORK_POLL_INTERVAL_MS = 1000; void UDPComponent::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - for (const auto &address : this->addresses_) { - struct sockaddr saddr {}; - socket::set_sockaddr(&saddr, sizeof(saddr), address, this->broadcast_port_); - this->sockaddrs_.push_back(saddr); + size_t address_count = 0; + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) + address_count++; + this->sockaddrs_.init(address_count); + for (const char *const *it = this->addresses_; it != nullptr && *it != nullptr; it++) { + const char *address = *it; + SockaddrEntry entry{}; + if (socket::set_sockaddr(&entry.sa, sizeof(entry), 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(entry); } // set up broadcast socket if (this->should_broadcast_) { - this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); - if (this->broadcast_socket_ == nullptr) { - this->status_set_error(LOG_STR("Could not create socket")); - this->mark_failed(); - return; +#ifdef USE_UDP_IPV6_SEND + if (this->ipv4_send_) +#endif + { + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); + if (this->broadcast_socket_ == nullptr) { + this->status_set_error(LOG_STR("Could not create socket")); + this->mark_failed(); + return; + } + int enable = 1; + auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + if (err != 0) { + this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); + // we can still continue + } + err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); + if (err != 0) { + this->status_set_warning(LOG_STR("Socket unable to set broadcast")); + } } - int enable = 1; - auto err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); - if (err != 0) { - this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); - // we can still continue - } - err = this->broadcast_socket_->setsockopt(SOL_SOCKET, SO_BROADCAST, &enable, sizeof(int)); - if (err != 0) { - this->status_set_warning(LOG_STR("Socket unable to set broadcast")); +#ifdef USE_UDP_IPV6_SEND + if (this->ipv6_send_) { + this->send_socket_v6_ = socket::socket_udp_send(AF_INET6, IPPROTO_IP); + if (this->send_socket_v6_ == nullptr) { + this->status_set_error(LOG_STR("Could not create IPv6 socket")); + this->mark_failed(); + return; + } + // Unicast needs no egress interface; multicast does + if (this->ipv6_multicast_) + this->poll_pending_ |= POLL_MCAST_IF; } +#endif } // create listening socket if we either want to subscribe to providers, or need to listen // for ping key broadcasts. if (this->should_listen_) { - this->listen_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + struct sockaddr_storage server {}; + socklen_t server_len; + if (this->listen_address_ != nullptr) { + server_len = + socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_); + // A multicast join needs a socket of the group's address family + this->listen_socket_ = socket::socket_udp_loop_monitored(server.ss_family, IPPROTO_IP); + this->poll_pending_ |= POLL_JOIN; +#ifdef USE_SOCKET_IPV6 + // An IPv6 group binds after the join, which supplies the scope id link-local groups need + if (server.ss_family == AF_INET6) + this->poll_pending_ |= POLL_BIND; +#endif + } else { +#ifdef USE_SOCKET_IPV6 + server_len = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), this->listen_port_); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET6, IPPROTO_IP); + // Clear IPV6_V6ONLY so IPv4 datagrams also arrive, as mapped addresses + int v6only = 0; + if (this->listen_socket_ != nullptr && + this->listen_socket_->setsockopt(IPPROTO_IPV6, IPV6_V6ONLY, &v6only, sizeof(v6only)) != 0) { + ESP_LOGW(TAG, "Failed to clear IPV6_V6ONLY: errno %d", errno); + this->status_set_warning(LOG_STR("IPv4 datagrams may not be received")); + } +#else + // Must stay IPv4: raw lwIP can have USE_NETWORK_IPV6 without IPv6 sockets + server_len = socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), "0.0.0.0", this->listen_port_); + this->listen_socket_ = socket::socket_udp_loop_monitored(AF_INET, IPPROTO_IP); +#endif + } if (this->listen_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -57,63 +115,102 @@ void UDPComponent::setup() { this->status_set_warning(LOG_STR("Socket unable to set reuseaddr")); // we can still continue } - struct sockaddr_in server {}; - server.sin_family = AF_INET; - server.sin_addr.s_addr = ESPHOME_INADDR_ANY; - server.sin_port = htons(this->listen_port_); - - if (this->listen_address_.has_value()) { - // Only 16 bytes needed for IPv4, but use standard size for consistency - char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; - this->listen_address_.value().str_to(addr_buf); - struct ip_mreq imreq = {}; - imreq.imr_interface.s_addr = ESPHOME_INADDR_ANY; - inet_aton(addr_buf, &imreq.imr_multiaddr); - server.sin_addr.s_addr = imreq.imr_multiaddr.s_addr; - ESP_LOGD(TAG, "Join multicast %s", addr_buf); - err = this->listen_socket_->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)); - if (err < 0) { - ESP_LOGE(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); - this->status_set_error(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); + if ((this->poll_pending_ & POLL_BIND) == 0) { + err = this->listen_socket_->bind((struct sockaddr *) &server, server_len); + if (err != 0) { + ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); + this->status_set_error(LOG_STR("Unable to bind socket")); this->mark_failed(); return; } } + } + if (this->poll_pending_ != 0) { + // Joins and the IPv6 egress interface need the network up; RP2040 has no interface before then + this->set_interval(NETWORK_POLL_ID, NETWORK_POLL_INTERVAL_MS, [this]() { + if (network::is_connected() && this->network_poll_()) + this->cancel_interval(NETWORK_POLL_ID); + }); + } +} - err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); - if (err != 0) { - ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); - this->status_set_error(LOG_STR("Unable to bind socket")); - this->mark_failed(); - return; +bool UDPComponent::network_poll_() { + bool ok = true; + if (this->poll_pending_ & POLL_JOIN) { +#ifdef USE_SOCKET_IPV6 + uint32_t *if_index = &this->mcast_ifindex_; +#else + uint32_t *if_index = nullptr; +#endif + if (socket::join_multicast_group(this->listen_socket_.get(), this->listen_address_, if_index)) { + ESP_LOGD(TAG, "Joined multicast %s", this->listen_address_); + this->poll_pending_ &= ~POLL_JOIN; + this->poll_failed_ &= ~POLL_JOIN; + } else { + this->set_poll_warning_(POLL_JOIN, LOG_STR("Failed to join multicast group")); + ok = false; + } + } +#ifdef USE_SOCKET_IPV6 + if (ok && (this->poll_pending_ & POLL_BIND)) { + struct sockaddr_storage server {}; + socklen_t server_len = + socket::set_sockaddr((struct sockaddr *) &server, sizeof(server), this->listen_address_, this->listen_port_); + // POSIX bind() on a link-local group needs it; lwIP ignores it + reinterpret_cast(&server)->sin6_scope_id = this->mcast_ifindex_; + if (this->listen_socket_->bind((struct sockaddr *) &server, server_len) == 0) { + this->poll_pending_ &= ~POLL_BIND; + this->poll_failed_ &= ~POLL_BIND; + } else { + this->set_poll_warning_(POLL_BIND, LOG_STR("Unable to bind socket")); + ok = false; } } #endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - // 8266 and RP2040 `Duino - for (const auto &address : this->addresses_) { - auto ipaddr = IPAddress(); - ipaddr.fromString(address); - this->ipaddrs_.push_back(ipaddr); +#ifdef USE_UDP_IPV6_SEND + // Only an IPv6 group's join supplies the index, so a failing IPv4 join must not hold back sends + const bool index_pending = (this->poll_pending_ & (POLL_JOIN | POLL_BIND)) == (POLL_JOIN | POLL_BIND); + if ((this->poll_pending_ & POLL_MCAST_IF) && !index_pending) { + // Index 0 picks the first eligible interface + if (socket::set_ipv6_multicast_if(this->send_socket_v6_.get(), this->mcast_ifindex_)) { + this->poll_pending_ &= ~POLL_MCAST_IF; + this->poll_failed_ &= ~POLL_MCAST_IF; + } else { + this->set_poll_warning_(POLL_MCAST_IF, LOG_STR("Failed to set IPv6 multicast interface")); + ok = false; + } } - if (this->should_listen_) - this->udp_client_.begin(this->listen_port_); #endif + if (!ok) + return false; + if (this->poll_warning_) { + this->poll_warning_ = false; + this->status_clear_warning(); + } + return true; +} + +void UDPComponent::set_poll_warning_(PollStep step, const LogString *what) { + // Log the first failure of each step only; the poll keeps retrying every second + if (this->poll_failed_ & step) + return; + this->poll_failed_ |= step; + ESP_LOGW(TAG, "%s. Error %d", LOG_STR_ARG(what), errno); + // One warning bit: only a warning raised here is cleared on success, one from setup() stays + if (!this->status_has_warning()) { + this->status_set_warning(what); + this->poll_warning_ = true; + } } void UDPComponent::loop() { if (this->should_listen_) { std::array buf; for (;;) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) + if (!this->listen_socket_->ready()) + break; auto len = this->listen_socket_->read(buf.data(), buf.size()); -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - auto len = this->udp_client_.parsePacket(); - if (len > 0) - len = this->udp_client_.read(buf.data(), buf.size()); -#endif if (len <= 0) break; size_t packet_len = static_cast(len); @@ -129,12 +226,12 @@ 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()) { - char addr_buf[network::IP_ADDRESS_BUFFER_SIZE]; - ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_.value().str_to(addr_buf)); + if (this->listen_address_ != nullptr) { + ESP_LOGCONFIG(TAG, " Listen address: %s", this->listen_address_); } ESP_LOGCONFIG(TAG, " Broadcasting: %s\n" @@ -143,26 +240,27 @@ void UDPComponent::dump_config() { } void UDPComponent::send_packet(const uint8_t *data, size_t size) { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - for (const auto &saddr : this->sockaddrs_) { - auto result = this->broadcast_socket_->sendto(data, size, 0, &saddr, sizeof(saddr)); + if (this->is_failed()) + return; // a send socket may be missing + for (const auto &entry : this->sockaddrs_) { +#ifdef USE_UDP_IPV6_SEND + const bool v6 = entry.sa.sa_family == AF_INET6; + // Until the egress interface is set, a multicast packet would leave on the wrong one + if (v6 && (this->poll_pending_ & POLL_MCAST_IF) && entry.in6.sin6_addr.s6_addr[0] == 0xff) { + ESP_LOGV(TAG, "Egress interface not set yet, multicast packet dropped"); + continue; + } + socket::UDPSendSocket *sock = v6 ? this->send_socket_v6_.get() : this->broadcast_socket_.get(); + socklen_t sockaddr_size = v6 ? sizeof(sockaddr_in6) : sizeof(sockaddr_in); +#else + socket::UDPSendSocket *sock = this->broadcast_socket_.get(); + socklen_t sockaddr_size = sizeof(sockaddr_in); +#endif + auto result = sock->sendto(data, size, 0, &entry.sa, sockaddr_size); if (result < 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } } -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - auto iface = IPAddress(0, 0, 0, 0); - for (const auto &saddr : this->ipaddrs_) { - if (this->udp_client_.beginPacketMulticast(saddr, this->broadcast_port_, iface, 128) != 0) { - this->udp_client_.write(data, size); - auto result = this->udp_client_.endPacket(); - if (result == 0) { - ESP_LOGW(TAG, "udp.write() error"); - } - } - } -#endif } } // namespace esphome::udp diff --git a/esphome/components/udp/udp_component.h b/esphome/components/udp/udp_component.h index 274e0119ee..b4dd4b2ac4 100644 --- a/esphome/components/udp/udp_component.h +++ b/esphome/components/udp/udp_component.h @@ -3,13 +3,7 @@ #include "esphome/core/defines.h" #ifdef USE_NETWORK #include "esphome/core/helpers.h" -#include "esphome/components/network/ip_address.h" -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" -#endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP -#include -#endif #include #include #include @@ -20,14 +14,23 @@ 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; - void set_listen_address(const char *listen_addr) { this->listen_address_ = network::IPAddress(listen_addr); } + /// nullptr-terminated table of addresses; must outlive the component. + void set_addresses(const char *const *addresses) { this->addresses_ = addresses; } + /// Multicast group literal; must outlive the component. + void set_listen_address(const char *listen_addr) { this->listen_address_ = listen_addr; } void set_listen_port(uint16_t port) { this->listen_port_ = port; } void set_broadcast_port(uint16_t port) { this->broadcast_port_ = port; } void set_should_broadcast() { this->should_broadcast_ = true; } void set_should_listen() { this->should_listen_ = true; } +#ifdef USE_UDP_IPV6_SEND + /// This instance has IPv6 destinations; multicast ones also need an egress interface. + void set_ipv6_send(bool multicast) { + this->ipv6_send_ = true; + this->ipv6_multicast_ = multicast; + } + /// An instance whose destinations are all IPv6 needs no IPv4 send socket + void set_ipv4_send(bool ipv4_send) { this->ipv4_send_ = ipv4_send; } +#endif template void add_listener(F &&listener) { this->packet_listeners_.add(std::forward(listener)); } void setup() override; void loop() override; @@ -37,24 +40,43 @@ class UDPComponent final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }; protected: + enum PollStep : uint8_t { POLL_JOIN = 1, POLL_BIND = 2, POLL_MCAST_IF = 4 }; + + union SockaddrEntry { + sockaddr sa; +#ifdef USE_UDP_IPV6_SEND + sockaddr_in6 in6; // sizes the storage for IPv6 destinations +#endif + }; + + /// One pass of the network poll; true once nothing is left to do. + bool network_poll_(); + void set_poll_warning_(PollStep step, const LogString *what); + + CallbackManager)> packet_listeners_{}; + std::unique_ptr broadcast_socket_ = nullptr; + std::unique_ptr listen_socket_ = nullptr; +#ifdef USE_UDP_IPV6_SEND + std::unique_ptr send_socket_v6_ = nullptr; +#endif + FixedVector sockaddrs_{}; + const char *const *addresses_{nullptr}; + const char *listen_address_{nullptr}; +#ifdef USE_SOCKET_IPV6 + uint32_t mcast_ifindex_{0}; +#endif uint16_t listen_port_{}; uint16_t broadcast_port_{}; bool should_broadcast_{}; bool should_listen_{}; - CallbackManager)> packet_listeners_{}; - -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr broadcast_socket_ = nullptr; - std::unique_ptr listen_socket_ = nullptr; - std::vector sockaddrs_{}; + uint8_t poll_pending_{}; // PollStep bits + uint8_t poll_failed_{}; // PollStep bits already logged + bool poll_warning_{}; +#ifdef USE_UDP_IPV6_SEND + bool ipv4_send_{true}; + bool ipv6_send_{}; + bool ipv6_multicast_{}; #endif -#ifdef USE_SOCKET_IMPL_LWIP_TCP - std::vector ipaddrs_{}; - WiFiUDP udp_client_{}; -#endif - FixedVector addresses_{}; - - optional listen_address_{}; }; } // namespace esphome::udp diff --git a/esphome/components/ufire_ec/__init__.py b/esphome/components/ufire_ec/__init__.py index 08f36c7934..e5461b4ff7 100644 --- a/esphome/components/ufire_ec/__init__.py +++ b/esphome/components/ufire_ec/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@pvizeli"] +DOMAIN = "ufire_ec" 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.cpp b/esphome/components/ufire_ec/ufire_ec.cpp index 72bf837409..1bb1222941 100644 --- a/esphome/components/ufire_ec/ufire_ec.cpp +++ b/esphome/components/ufire_ec/ufire_ec.cpp @@ -4,7 +4,9 @@ namespace esphome::ufire_ec { -static const char *const TAG = "ufire_ec"; +ESPHOME_LOG_TAG(TAG, "ufire_ec"); + +static constexpr uint32_t DATA_TIMEOUT_ID = 0; void UFireECComponent::setup() { uint8_t version; @@ -45,7 +47,7 @@ void UFireECComponent::update() { } if (wait > 0) { - this->set_timeout("data", wait, [this]() { this->update_internal_(); }); + this->set_timeout(DATA_TIMEOUT_ID, wait, [this]() { this->update_internal_(); }); } } 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/__init__.py b/esphome/components/ufire_ise/__init__.py index 08f36c7934..9fb4cb9c65 100644 --- a/esphome/components/ufire_ise/__init__.py +++ b/esphome/components/ufire_ise/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@pvizeli"] +DOMAIN = "ufire_ise" 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.cpp b/esphome/components/ufire_ise/ufire_ise.cpp index d595b37a83..aa899968e4 100644 --- a/esphome/components/ufire_ise/ufire_ise.cpp +++ b/esphome/components/ufire_ise/ufire_ise.cpp @@ -5,7 +5,9 @@ namespace esphome::ufire_ise { -static const char *const TAG = "ufire_ise"; +ESPHOME_LOG_TAG(TAG, "ufire_ise"); + +static constexpr uint32_t DATA_TIMEOUT_ID = 0; void UFireISEComponent::setup() { uint8_t version; @@ -38,7 +40,7 @@ void UFireISEComponent::update() { } // Wait until measurement are taken - this->set_timeout("data", wait, [this]() { this->update_internal_(); }); + this->set_timeout(DATA_TIMEOUT_ID, wait, [this]() { this->update_internal_(); }); } void UFireISEComponent::update_internal_() { 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/__init__.py b/esphome/components/ufm01/__init__.py index 85ca0eecae..1846958e59 100644 --- a/esphome/components/ufm01/__init__.py +++ b/esphome/components/ufm01/__init__.py @@ -1,10 +1,15 @@ +from dataclasses import dataclass + +from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID +from esphome.core import CORE from esphome.types import ConfigType CODEOWNERS = ["@ljungqvist"] +DOMAIN = "ufm01" MULTI_CONF = True @@ -15,6 +20,23 @@ UFM01Component = ufm01_ns.class_("UFM01Component", uart.UARTDevice, cg.Component CONF_UFM01_ID = "ufm01_id" + +@dataclass +class UFM01Data: + clear_action_used: bool = False + + +def _get_data() -> UFM01Data: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UFM01Data() + return CORE.data[DOMAIN] + + +def _note_clear_action(config: ConfigType) -> ConfigType: + _get_data().clear_action_used = True + return config + + CONFIG_SCHEMA = ( cv.Schema( { @@ -25,6 +47,13 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA = automation.maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(UFM01Component), + }, + _note_clear_action, +) + FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( "ufm01", require_tx=True, @@ -40,3 +69,12 @@ 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 _get_data().clear_action_used: + cg.add_define("USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION") + + +automation.register_apply_action( + "ufm01.clear_accumulated_flow", + CLEAR_ACCUMULATED_FLOW_ACTION_SCHEMA, + automation.ApplyCall("request_clear_accumulated_flow()"), +) 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/text_sensor.py b/esphome/components/ufm01/text_sensor.py new file mode 100644 index 0000000000..dcb5d3294c --- /dev/null +++ b/esphome/components/ufm01/text_sensor.py @@ -0,0 +1,34 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ENTITY_CATEGORY_DIAGNOSTIC, ICON_CHIP, ICON_FINGERPRINT +from esphome.types import ConfigType + +from . import CONF_UFM01_ID, UFM01Component + +DEPENDENCIES = ["ufm01"] + +CONF_METER_ID = "meter_id" +CONF_SOFTWARE_VERSION = "software_version" + +CONFIG_SCHEMA = { + cv.GenerateID(CONF_UFM01_ID): cv.use_id(UFM01Component), + cv.Optional(CONF_METER_ID): text_sensor.text_sensor_schema( + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, icon=ICON_FINGERPRINT + ), + cv.Optional(CONF_SOFTWARE_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_UFM01_ID]) + sensors = text_sensor.sub_text_sensors(config) + if await sensors(CONF_METER_ID, hub.set_meter_id_text_sensor) is not None: + cg.add_define("USE_UFM01_METER_ID") + if ( + await sensors(CONF_SOFTWARE_VERSION, hub.set_software_version_text_sensor) + is not None + ): + cg.add_define("USE_UFM01_SOFTWARE_VERSION") diff --git a/esphome/components/ufm01/ufm01.cpp b/esphome/components/ufm01/ufm01.cpp index 880132bad3..47ff2165f8 100644 --- a/esphome/components/ufm01/ufm01.cpp +++ b/esphome/components/ufm01/ufm01.cpp @@ -1,18 +1,20 @@ #include "ufm01.h" -#include "esphome/core/hal.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include #include #include #include namespace esphome::ufm01 { -static const char *const TAG = "ufm01"; +ESPHOME_LOG_TAG(TAG, "ufm01"); static constexpr uint8_t COMMAND_ACK = 0xE5; static constexpr uint32_t COMMAND_ACK_TIMEOUT_MS = 500; +static constexpr uint32_t CLEAR_QUEUE_TIMEOUT_MS = 15000; static constexpr uint32_t STARTUP_DELAY_MS = 2000; static constexpr uint32_t POST_RESET_DELAY_MS = 2000; static constexpr uint32_t RESET_RETRY_DELAY_MS = 800; @@ -20,23 +22,40 @@ static constexpr uint32_t STARTUP_RETRY_MS = 3000; static constexpr uint32_t PASSIVE_POLL_INTERVAL_MS = 1000; static constexpr uint32_t ACTIVE_STALE_MS = 5000; static constexpr uint32_t PASSIVE_READ_TIMEOUT_MS = 1000; +static constexpr uint32_t SOFTWARE_VERSION_READ_TIMEOUT_MS = 2000; +static constexpr uint32_t SOFTWARE_VERSION_RETRY_MS = 30000; +static constexpr uint8_t SOFTWARE_VERSION_MAX_ATTEMPTS = 3; static constexpr uint32_t ACTIVE_FRAME_TIMEOUT_MS = 3000; +// After this many consecutive passive poll failures, re-run the reset/startup sequence +static constexpr uint8_t PASSIVE_FAIL_ESCALATE_COUNT = 8; static constexpr float L_PER_M3 = 1000.0f; static constexpr float M3_PER_L = 1.0f / L_PER_M3; static constexpr std::array ACTIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x00, 0x5C, 0x16}; +static constexpr std::array PASSIVE_MODE = {0xFE, 0xFE, 0x11, 0x5C, 0x01, 0x5D, 0x16}; static constexpr std::array CLEAR_ACCUMULATED_FLOW = {0xFE, 0xFE, 0x11, 0x5A, 0xFD, 0x57, 0x16}; static constexpr std::array RESET_DEVICE = {0xFE, 0xFE, 0x11, 0x5D, 0xCB, 0x28, 0x16}; static constexpr std::array READ_SENSOR_DATA_NO_ID = {0xFE, 0xFE, 0x11, 0x5B, 0x0F, 0x6A, 0x16}; +static constexpr std::array READ_SENSOR_DATA_WITH_ID = {0xFE, 0xFE, 0x11, 0x5B, 0xCB, 0x26, 0x16}; +static constexpr std::array GET_SOFTWARE_VERSION = {0xFE, 0xFE, 0x11, 0x5E, 0x62, 0xC0, 0x16}; // Active-mode frame layout (datasheet Table 7) +static constexpr size_t FRAME_DEVICE_ID_INDEX = 2; static constexpr size_t FRAME_CHECKSUM_INDEX = 30; static constexpr size_t FRAME_STOP_INDEX = 31; static constexpr uint8_t FRAME_START_BYTE_1 = 0x3C; static constexpr uint8_t FRAME_START_BYTE_2 = 0x32; static constexpr uint8_t PASSIVE_START_BYTE_2 = 0x64; +static constexpr uint8_t PASSIVE_START_BYTE_2_WITH_ID = 0x96; static constexpr uint8_t FRAME_STOP_BYTE = 0x16; +static constexpr size_t PASSIVE_WITH_ID_DEVICE_ID_INDEX = 2; +static constexpr uint8_t PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX = 8; +static constexpr uint8_t PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX = 22; +static constexpr uint8_t PASSIVE_WITH_ID_TEMP_FLAG_INDEX = 31; +static constexpr uint8_t PASSIVE_WITH_ID_ST1_INDEX = 35; +static constexpr size_t PASSIVE_WITH_ID_CHECKSUM_INDEX = 37; +static constexpr size_t PASSIVE_WITH_ID_STOP_INDEX = 38; static constexpr uint8_t FRAME_INDEX_INSTANT_FLOW_FLAG = 15; static constexpr uint8_t FRAME_INDEX_RESERVED_SECTION = 21; static constexpr uint8_t FRAME_INDEX_TEMP_FLAG = 24; @@ -58,8 +77,40 @@ static constexpr uint8_t ST2_UFC_ERROR_MASK = 0x20; static constexpr uint8_t ST2_FLOW_DIRECTION_WRONG_MASK = 0x08; static constexpr uint8_t ST2_FLOW_RATE_OUT_OF_RANGE_MASK = 0x04; +static_assert(SOFTWARE_VERSION_RESPONSE_SIZE <= PASSIVE_FRAME_MAX_SIZE, "software version read reuses passive_frame_"); +static constexpr size_t SOFTWARE_VERSION_CHECKSUM_INDEX = 5; +static constexpr size_t SOFTWARE_VERSION_STOP_INDEX = 6; + static float to_float(uint8_t data) { return (data >> 4) * 10 + (data & 0x0F); } +static bool bcd_to_digits(const uint8_t *data, size_t len, char *out) { + bool all_zero = true; + for (size_t i = 0; i < len; ++i) { + const uint8_t lo = data[i] & 0x0F; + const uint8_t hi = data[i] >> 4; + if (lo > 9 || hi > 9) + return false; + const size_t pos = (len - 1 - i) * 2; + if (hi != 0) + all_zero = false; + out[pos] = static_cast('0' + hi); + if (lo != 0) + all_zero = false; + out[pos + 1] = static_cast('0' + lo); + } + out[len * 2] = '\0'; + return !all_zero; +} + +static bool validate_software_version_response(const uint8_t data[SOFTWARE_VERSION_RESPONSE_SIZE]) { + if (data[0] != COMMAND_ACK || data[SOFTWARE_VERSION_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 1; i < SOFTWARE_VERSION_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[SOFTWARE_VERSION_CHECKSUM_INDEX] == sum; +} + static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t expected, const char *name) { if (data[index] == expected) return true; @@ -68,9 +119,7 @@ static bool check_byte(const uint8_t data[FRAME_SIZE], size_t index, uint8_t exp } static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { - uint8_t sum = 0; - for (size_t i = 0; i < FRAME_CHECKSUM_INDEX; ++i) - sum += data[i]; + const uint8_t sum = sum_bytes(data, FRAME_CHECKSUM_INDEX); return check_byte(data, 0, FRAME_START_BYTE_1, "start byte 1") && check_byte(data, 1, FRAME_START_BYTE_2, "start byte 2") && check_byte(data, FRAME_INDEX_INSTANT_FLOW_FLAG, FRAME_FLAG_INSTANT_FLOW, "instant flow flag") && @@ -83,31 +132,45 @@ static bool validate_active_frame(const uint8_t data[FRAME_SIZE]) { static bool validate_passive_frame(const uint8_t data[PASSIVE_FRAME_SIZE]) { if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2 || data[22] != FRAME_STOP_BYTE) return false; - uint8_t sum = 0; - for (size_t i = 0; i < 21; ++i) - sum += data[i]; - return data[21] == (sum & 0xFF); + return data[21] == sum_bytes(data, 21); } -static void passive_no_id_to_active_frame(const uint8_t passive[PASSIVE_FRAME_SIZE], uint8_t active[FRAME_SIZE]) { +#ifdef USE_UFM01_METER_ID +static bool validate_passive_with_id_frame(const uint8_t data[PASSIVE_FRAME_WITH_ID_SIZE]) { + if (data[0] != FRAME_START_BYTE_1 || data[1] != PASSIVE_START_BYTE_2_WITH_ID || + data[PASSIVE_WITH_ID_STOP_INDEX] != FRAME_STOP_BYTE) + return false; + uint8_t sum = 0; + for (size_t i = 0; i < PASSIVE_WITH_ID_CHECKSUM_INDEX; ++i) + sum += data[i]; + return data[PASSIVE_WITH_ID_CHECKSUM_INDEX] == sum; +} +#endif + +// Start index of each run in a passive reply; each run maps to the matching run of the active frame +struct PassiveLayout { + uint8_t acc_flow; // flag + 6 bytes, active 8..14 + uint8_t instant_flow; // flag + 5 bytes, active 15..20 + uint8_t temperature; // flag + 3 bytes, active 24..27 + uint8_t status; // ST1, ST2, checksum, active 28..30 +}; +static constexpr PassiveLayout PASSIVE_NO_ID_LAYOUT{2, 9, 15, 19}; +#ifdef USE_UFM01_METER_ID +static constexpr PassiveLayout PASSIVE_WITH_ID_LAYOUT{PASSIVE_WITH_ID_ACC_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_INSTANT_FLOW_FLAG_INDEX, + PASSIVE_WITH_ID_TEMP_FLAG_INDEX, PASSIVE_WITH_ID_ST1_INDEX}; +#endif + +static void passive_to_active_frame(const uint8_t *passive, const PassiveLayout &layout, uint8_t active[FRAME_SIZE]) { std::memset(active, 0, FRAME_SIZE); active[0] = FRAME_START_BYTE_1; active[1] = FRAME_START_BYTE_2; - active[7] = 0x01; - active[8] = passive[2]; - for (size_t i = 0; i < 6; ++i) - active[9 + i] = passive[3 + i]; - active[15] = passive[9]; - for (size_t i = 0; i < 5; ++i) - active[16 + i] = passive[10 + i]; - active[21] = FRAME_FLAG_RESERVED_SECTION; - active[24] = passive[15]; - for (size_t i = 0; i < 3; ++i) - active[25 + i] = passive[16 + i]; - active[28] = passive[19]; - active[29] = passive[20]; - active[30] = passive[21]; - active[31] = FRAME_STOP_BYTE; + std::copy_n(&passive[layout.acc_flow], 7, &active[FRAME_ACC_FLOW_FLAG_INDEX]); + std::copy_n(&passive[layout.instant_flow], 6, &active[FRAME_INDEX_INSTANT_FLOW_FLAG]); + active[FRAME_INDEX_RESERVED_SECTION] = FRAME_FLAG_RESERVED_SECTION; + std::copy_n(&passive[layout.temperature], 4, &active[FRAME_INDEX_TEMP_FLAG]); + std::copy_n(&passive[layout.status], 3, &active[FRAME_ST1_INDEX]); + active[FRAME_STOP_INDEX] = FRAME_STOP_BYTE; } static float read_accumulated_flow(const uint8_t data[FRAME_SIZE]) { @@ -123,8 +186,10 @@ static float read_flow(const uint8_t data[FRAME_SIZE]) { M3_PER_L; } +static constexpr size_t LOG_HEX_MAX_SIZE = std::max(FRAME_SIZE, PASSIVE_FRAME_MAX_SIZE); + static void log_hex(const uint8_t *data, size_t len) { - char hex_buf[format_hex_pretty_size(FRAME_SIZE)]; + char hex_buf[format_hex_pretty_size(LOG_HEX_MAX_SIZE)]; ESP_LOGD(TAG, "%s", format_hex_pretty_to(hex_buf, data, len, ' ')); } @@ -162,7 +227,6 @@ void UFM01Component::send_command_no_wait_(const std::array &command this->flush(); } -// Drains whatever is currently in the RX buffer, looking for a command ACK. bool UFM01Component::consume_ack_() { while (this->available()) { uint8_t byte; @@ -175,29 +239,65 @@ bool UFM01Component::consume_ack_() { return false; } -bool UFM01Component::send_command_(const std::array &command) { - this->send_command_no_wait_(command); - const uint32_t start = millis(); - while (millis() - start < COMMAND_ACK_TIMEOUT_MS) { - if (this->consume_ack_()) - return true; - delay(1); - } - return false; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +bool UFM01Component::can_start_clear_action_() const { + return +#ifdef USE_UFM01_SOFTWARE_VERSION + !this->software_version_read_pending_ && +#endif + (this->operating_mode_ == OperatingMode::ACTIVE_STREAM || + (this->operating_mode_ == OperatingMode::PASSIVE_POLL && !this->passive_read_pending_)); } -bool UFM01Component::reset_device_() { return this->send_command_(RESET_DEVICE); } +void UFM01Component::request_clear_accumulated_flow() { + if (this->clear_pending_) { + ESP_LOGD(TAG, "Clear accumulated flow already pending"); + return; + } + this->clear_pending_ = true; + this->pending_clear_sent_ = false; + this->pending_clear_start_ms_ = App.get_loop_component_start_time(); +} -bool UFM01Component::clear_accumulated_flow_() { return this->send_command_(CLEAR_ACCUMULATED_FLOW); } +void UFM01Component::finish_pending_clear_() { + this->clear_pending_ = false; + this->pending_clear_sent_ = false; +} -bool UFM01Component::set_active_mode_() { return this->send_command_(ACTIVE_MODE); } +void UFM01Component::loop_pending_clear_action_(uint32_t now) { + if (!this->clear_pending_) + return; + if (!this->pending_clear_sent_) { + if (!this->can_start_clear_action_()) { + if (now - this->pending_clear_start_ms_ < CLEAR_QUEUE_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow timed out waiting for an idle UART"); + this->finish_pending_clear_(); + return; + } + this->pending_clear_start_ms_ = now; + this->pending_clear_sent_ = true; + this->send_command_no_wait_(CLEAR_ACCUMULATED_FLOW); + return; + } + if (this->consume_ack_()) { + ESP_LOGI(TAG, "Clear accumulated flow acknowledged"); + this->finish_pending_clear_(); + return; + } + if (now - this->pending_clear_start_ms_ < COMMAND_ACK_TIMEOUT_MS) + return; + ESP_LOGW(TAG, "Clear accumulated flow not acknowledged"); + this->finish_pending_clear_(); +} +#endif // USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION float UFM01Component::get_setup_priority() const { return setup_priority::LATE; } void UFM01Component::setup() { ESP_LOGI(TAG, "Setting up UFM-01..."); this->startup_wait_ms_ = STARTUP_DELAY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, App.get_loop_component_start_time()); } void UFM01Component::dump_config() { @@ -213,9 +313,123 @@ void UFM01Component::dump_config() { LOG_BINARY_SENSOR(" ", "Empty Tube", this->empty_tube_binary_sensor_); LOG_BINARY_SENSOR(" ", "Flow Rate Out Of Range", this->flow_rate_out_of_range_binary_sensor_); #endif +#ifdef USE_UFM01_METER_ID + LOG_TEXT_SENSOR(" ", "Meter ID", this->meter_id_text_sensor_); +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + LOG_TEXT_SENSOR(" ", "Software Version", this->software_version_text_sensor_); +#endif } -void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { +// The binary sensors keep their last state while readings are stale; only flow and temperature go unknown +void UFM01Component::publish_stale_flow_and_temperature_() { +#ifdef USE_SENSOR + if (this->flow_sensor_ != nullptr) + this->flow_sensor_->publish_state(NAN); + if (this->temperature_sensor_ != nullptr) + this->temperature_sensor_->publish_state(NAN); +#endif +} + +#ifdef USE_UFM01_METER_ID +void UFM01Component::publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]) { + if (this->meter_id_text_sensor_ == nullptr || this->meter_id_published_) + return; + + char meter_id_str[DEVICE_ID_STRING_LENGTH + 1]; + if (!bcd_to_digits(&data[FRAME_DEVICE_ID_INDEX], DEVICE_ID_LENGTH, meter_id_str)) { + if (!this->meter_id_invalid_logged_) { + ESP_LOGW(TAG, "Meter ID in frame is zero or not BCD, leaving the sensor unknown"); + this->meter_id_invalid_logged_ = true; + } + return; + } + + this->meter_id_text_sensor_->publish_state(meter_id_str); + this->meter_id_published_ = true; + ESP_LOGI(TAG, "UFM-01 meter ID: %s", meter_id_str); +} +#endif + +#ifdef USE_UFM01_SOFTWARE_VERSION +void UFM01Component::start_software_version_read_(uint32_t now) { + this->send_command_no_wait_(GET_SOFTWARE_VERSION); + this->passive_index_ = 0; + this->passive_start_ms_ = now; + this->software_version_read_pending_ = true; +} + +ReadResult UFM01Component::continue_software_version_read_(uint32_t now) { + while (this->available() && this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + uint8_t byte; + if (!this->read_byte(&byte)) + break; + // The tail of an in-flight active-stream frame can arrive before the reply + if (this->passive_index_ == 0 && byte != COMMAND_ACK) + continue; + this->passive_frame_[this->passive_index_++] = byte; + } + + if (this->passive_index_ < SOFTWARE_VERSION_RESPONSE_SIZE) { + if (now - this->passive_start_ms_ < SOFTWARE_VERSION_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "software version read timeout (%u/%u bytes)", this->passive_index_, + static_cast(SOFTWARE_VERSION_RESPONSE_SIZE)); + return ReadResult::READ_RESULT_FAILURE; + } + + if (!validate_software_version_response(this->passive_frame_)) { + log_hex(this->passive_frame_, SOFTWARE_VERSION_RESPONSE_SIZE); + ESP_LOGW(TAG, "invalid software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_) { + char version_str[SOFTWARE_VERSION_STRING_LENGTH + 1]; + if (!bcd_to_digits(&this->passive_frame_[1], SOFTWARE_VERSION_LENGTH, version_str)) { + ESP_LOGW(TAG, "invalid BCD data in software version response"); + return ReadResult::READ_RESULT_FAILURE; + } + this->software_version_text_sensor_->publish_state(version_str); + this->software_version_published_ = true; + ESP_LOGI(TAG, "UFM-01 software version: %s", version_str); + } + + return ReadResult::READ_RESULT_SUCCESS; +} + +void UFM01Component::finish_software_version_read_(ReadResult result, uint32_t now) { + this->software_version_read_pending_ = false; + this->last_software_version_attempt_ms_ = now; + if (result == ReadResult::READ_RESULT_FAILURE && + ++this->software_version_failures_ == SOFTWARE_VERSION_MAX_ATTEMPTS) { + ESP_LOGW(TAG, "Software version not read after %u attempts, giving up", SOFTWARE_VERSION_MAX_ATTEMPTS); + } +} + +// Returns true while a software version read owns the UART, so the caller skips its own work +bool UFM01Component::service_software_version_(uint32_t now) { + if (this->software_version_read_pending_) { + const ReadResult result = this->continue_software_version_read_(now); + if (result != ReadResult::READ_RESULT_PENDING) + this->finish_software_version_read_(result, now); + return true; + } + if (this->software_version_text_sensor_ == nullptr || this->software_version_published_ || + this->software_version_failures_ >= SOFTWARE_VERSION_MAX_ATTEMPTS || this->passive_read_pending_) + return false; + if (this->last_software_version_attempt_ms_ != 0 && + now - this->last_software_version_attempt_ms_ < SOFTWARE_VERSION_RETRY_MS) + return false; + this->start_software_version_read_(now); + return true; +} +#endif // USE_UFM01_SOFTWARE_VERSION + +void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now) { +#ifdef USE_UFM01_METER_ID + this->publish_meter_id_from_frame_(data); +#endif bool empty_tube = read_empty_tube(data); #ifdef USE_BINARY_SENSOR if (this->ufc_chip_error_binary_sensor_ != nullptr) @@ -234,10 +448,7 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->accumulated_flow_sensor_->publish_state(read_accumulated_flow(data)); if (empty_tube) { - if (this->flow_sensor_ != nullptr) - this->flow_sensor_->publish_state(NAN); - if (this->temperature_sensor_ != nullptr) - this->temperature_sensor_->publish_state(NAN); + this->publish_stale_flow_and_temperature_(); } else { if (this->flow_sensor_ != nullptr) this->flow_sensor_->publish_state(read_flow(data)); @@ -245,12 +456,12 @@ void UFM01Component::on_active_frame_(uint8_t data[FRAME_SIZE]) { this->temperature_sensor_->publish_state(read_temperature(data)); } #endif - this->last_valid_frame_ms_ = millis(); + this->last_valid_frame_ms_ = now; this->status_clear_warning(); this->status_clear_error(); } -bool UFM01Component::process_active_stream_() { +bool UFM01Component::process_active_stream_(uint32_t now) { bool got_valid_frame = false; while (this->available()) { @@ -261,7 +472,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; } @@ -269,7 +480,7 @@ bool UFM01Component::process_active_stream_() { continue; if (validate_active_frame(this->data_)) { - this->on_active_frame_(this->data_); + this->on_active_frame_(this->data_, now); this->read_index_ = 0; got_valid_frame = true; continue; @@ -292,33 +503,89 @@ bool UFM01Component::process_active_stream_() { return got_valid_frame; } -void UFM01Component::set_startup_phase_(StartupPhase phase) { - this->startup_phase_ = phase; - this->phase_start_ms_ = millis(); -} - -void UFM01Component::enter_active_stream_(const char *reason) { - ESP_LOGI(TAG, "UFM-01 active stream %s", reason); - this->operating_mode_ = OperatingMode::ACTIVE_STREAM; +void UFM01Component::set_mode_(OperatingMode mode, uint32_t now) { + this->operating_mode_ = mode; + this->phase_start_ms_ = now; this->passive_read_pending_ = false; +#ifdef USE_UFM01_SOFTWARE_VERSION + this->software_version_read_pending_ = false; +#endif + this->consecutive_passive_failures_ = 0; } -void UFM01Component::start_passive_read_() { - this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); +void UFM01Component::set_startup_phase_(StartupPhase phase, uint32_t now) { + this->startup_phase_ = phase; + this->phase_start_ms_ = now; +} + +void UFM01Component::send_reset_(const LogString *status, uint32_t now) { + this->status_set_warning(status); + this->reset_retried_ = false; + this->send_command_no_wait_(RESET_DEVICE); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); +} + +void UFM01Component::enter_active_stream_(const LogString *reason, uint32_t now) { + ESP_LOGI(TAG, "UFM-01 active stream %s", LOG_STR_ARG(reason)); + this->set_mode_(OperatingMode::ACTIVE_STREAM, now); +} + +void UFM01Component::enter_passive_from_stale_(uint32_t now) { + ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); + this->publish_stale_flow_and_temperature_(); + // Flushes leftover stream bytes first; the ACK is not awaited, the next command's flush discards it + this->send_command_no_wait_(PASSIVE_MODE); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; + this->status_set_warning(LOG_STR("UFM-01 passive poll")); +} + +void UFM01Component::restart_startup_(const LogString *reason, uint32_t now) { + ESP_LOGW(TAG, "%s, re-initializing UFM-01", LOG_STR_ARG(reason)); + this->publish_stale_flow_and_temperature_(); + this->set_mode_(OperatingMode::STARTUP, now); + this->send_reset_(LOG_STR("re-initializing UFM-01"), now); +} + +void UFM01Component::note_passive_poll_result_(bool success, uint32_t now) { + if (success) { + this->consecutive_passive_failures_ = 0; + return; + } + this->status_set_warning(LOG_STR("UFM-01 passive poll failed")); + if (++this->consecutive_passive_failures_ < PASSIVE_FAIL_ESCALATE_COUNT) + return; + this->restart_startup_(LOG_STR("Passive poll failed repeatedly"), now); +} + +void UFM01Component::start_passive_read_(uint32_t now) { +#ifdef USE_UFM01_METER_ID + if (this->meter_id_text_sensor_ != nullptr && !this->meter_id_published_) { + this->passive_expected_size_ = PASSIVE_FRAME_WITH_ID_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2_WITH_ID; + this->send_command_no_wait_(READ_SENSOR_DATA_WITH_ID); + } else +#endif + { + this->passive_expected_size_ = PASSIVE_FRAME_SIZE; + this->passive_start_byte_2_ = PASSIVE_START_BYTE_2; + this->send_command_no_wait_(READ_SENSOR_DATA_NO_ID); + } this->passive_index_ = 0; - this->passive_start_ms_ = millis(); + this->passive_start_ms_ = now; } -// Accumulates the reply to a passive read request across loop iterations. -PassiveReadResult UFM01Component::continue_passive_read_() { - while (this->available() && this->passive_index_ < PASSIVE_FRAME_SIZE) { +ReadResult UFM01Component::continue_passive_read_(uint32_t now) { + const uint8_t expected_size = this->passive_expected_size_; + + while (this->available() && this->passive_index_ < expected_size) { uint8_t byte; if (!this->read_byte(&byte)) break; if (this->passive_index_ == 0 && byte != FRAME_START_BYTE_1) continue; - if (this->passive_index_ == 1 && byte != PASSIVE_START_BYTE_2) { + if (this->passive_index_ == 1 && byte != this->passive_start_byte_2_) { // The mismatched byte may itself be the start of the real frame this->passive_index_ = (byte == FRAME_START_BYTE_1) ? 1 : 0; continue; @@ -326,57 +593,69 @@ PassiveReadResult UFM01Component::continue_passive_read_() { this->passive_frame_[this->passive_index_++] = byte; } - if (this->passive_index_ < PASSIVE_FRAME_SIZE) { - if (millis() - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) - return PassiveReadResult::PASSIVE_READ_RESULT_PENDING; - ESP_LOGD(TAG, "passive read timeout (%zu/%zu bytes)", this->passive_index_, PASSIVE_FRAME_SIZE); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; - } - - if (!validate_passive_frame(this->passive_frame_)) { - log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); - ESP_LOGW(TAG, "invalid passive frame"); - return PassiveReadResult::PASSIVE_READ_RESULT_FAILURE; + if (this->passive_index_ < expected_size) { + if (now - this->passive_start_ms_ < PASSIVE_READ_TIMEOUT_MS) + return ReadResult::READ_RESULT_PENDING; + ESP_LOGD(TAG, "passive read timeout (%u/%u bytes)", this->passive_index_, expected_size); + return ReadResult::READ_RESULT_FAILURE; } uint8_t active_frame[FRAME_SIZE]; - passive_no_id_to_active_frame(this->passive_frame_, active_frame); - this->on_active_frame_(active_frame); - return PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS; +#ifdef USE_UFM01_METER_ID + if (expected_size == PASSIVE_FRAME_WITH_ID_SIZE) { + if (!validate_passive_with_id_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_WITH_ID_SIZE); + ESP_LOGW(TAG, "invalid passive frame with ID"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_WITH_ID_LAYOUT, active_frame); + // Device ID and the byte after it + std::copy_n(&this->passive_frame_[PASSIVE_WITH_ID_DEVICE_ID_INDEX], DEVICE_ID_LENGTH + 1, + &active_frame[FRAME_DEVICE_ID_INDEX]); + } else +#endif + { + if (!validate_passive_frame(this->passive_frame_)) { + log_hex(this->passive_frame_, PASSIVE_FRAME_SIZE); + ESP_LOGW(TAG, "invalid passive frame"); + return ReadResult::READ_RESULT_FAILURE; + } + passive_to_active_frame(this->passive_frame_, PASSIVE_NO_ID_LAYOUT, active_frame); + active_frame[FRAME_DEVICE_ID_INDEX + DEVICE_ID_LENGTH] = 0x01; + } + this->on_active_frame_(active_frame, now); + return ReadResult::READ_RESULT_SUCCESS; } -void UFM01Component::loop_startup_() { - const uint32_t elapsed = millis() - this->phase_start_ms_; +void UFM01Component::loop_startup_(uint32_t now) { + const uint32_t elapsed = now - this->phase_start_ms_; switch (this->startup_phase_) { case StartupPhase::WAIT: // Pick up an already-streaming device without resetting it - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < this->startup_wait_ms_) return; ESP_LOGD(TAG, "Running startup sequence"); - this->status_set_warning("initializing UFM-01"); - this->reset_retried_ = false; - this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->send_reset_(LOG_STR("initializing UFM-01"), now); return; case StartupPhase::RESET_WAIT_ACK: if (this->consume_ack_()) { - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); return; } if (elapsed < COMMAND_ACK_TIMEOUT_MS) return; if (!this->reset_retried_) { ESP_LOGW(TAG, "Reset not acknowledged, retrying in %" PRIu32 " ms", RESET_RETRY_DELAY_MS); - this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT); + this->set_startup_phase_(StartupPhase::RESET_RETRY_WAIT, now); } else { ESP_LOGW(TAG, "Reset failed during startup"); - this->set_startup_phase_(StartupPhase::POST_RESET_WAIT); + this->set_startup_phase_(StartupPhase::POST_RESET_WAIT, now); } return; @@ -385,91 +664,138 @@ void UFM01Component::loop_startup_() { return; this->reset_retried_ = true; this->send_command_no_wait_(RESET_DEVICE); - this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK); + this->set_startup_phase_(StartupPhase::RESET_WAIT_ACK, now); return; case StartupPhase::POST_RESET_WAIT: if (elapsed < POST_RESET_DELAY_MS) return; +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->software_version_text_sensor_ != nullptr && !this->software_version_published_ && + this->software_version_failures_ < SOFTWARE_VERSION_MAX_ATTEMPTS) { + this->start_software_version_read_(now); + this->set_startup_phase_(StartupPhase::SOFTWARE_VERSION_WAIT_REPLY, now); + return; + } +#endif this->send_command_no_wait_(ACTIVE_MODE); - this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); return; +#ifdef USE_UFM01_SOFTWARE_VERSION + case StartupPhase::SOFTWARE_VERSION_WAIT_REPLY: { + const ReadResult result = this->continue_software_version_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) + return; + if (result == ReadResult::READ_RESULT_SUCCESS) { + ESP_LOGD(TAG, "Software version read during startup"); + } else { + ESP_LOGW(TAG, "Software version read failed during startup, continuing"); + } + this->finish_software_version_read_(result, now); + this->send_command_no_wait_(ACTIVE_MODE); + this->set_startup_phase_(StartupPhase::ACTIVE_WAIT_FRAME, now); + return; + } +#endif + case StartupPhase::ACTIVE_WAIT_FRAME: // The command ACK (0xE5) is consumed by the frame parser as noise - if (this->process_active_stream_()) { - this->enter_active_stream_("started"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("started"), now); return; } if (elapsed < ACTIVE_FRAME_TIMEOUT_MS) return; - this->start_passive_read_(); - this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY); + this->send_command_no_wait_(PASSIVE_MODE); + this->set_startup_phase_(StartupPhase::SET_PASSIVE_WAIT_ACK, now); + return; + + case StartupPhase::SET_PASSIVE_WAIT_ACK: + if (this->consume_ack_()) { + ESP_LOGD(TAG, "Passive mode acknowledged during startup"); + } else if (elapsed < COMMAND_ACK_TIMEOUT_MS) { + return; + } else { + ESP_LOGW(TAG, "SET_PASSIVE_MODE not acknowledged during startup, continuing"); + } + this->start_passive_read_(now); + this->set_startup_phase_(StartupPhase::PASSIVE_WAIT_REPLY, now); return; case StartupPhase::PASSIVE_WAIT_REPLY: - switch (this->continue_passive_read_()) { - case PassiveReadResult::PASSIVE_READ_RESULT_PENDING: + switch (this->continue_passive_read_(now)) { + case ReadResult::READ_RESULT_PENDING: return; - case PassiveReadResult::PASSIVE_READ_RESULT_SUCCESS: + case ReadResult::READ_RESULT_SUCCESS: ESP_LOGI(TAG, "UFM-01 using passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = millis(); + this->set_mode_(OperatingMode::PASSIVE_POLL, now); + this->last_poll_ms_ = now; return; - case PassiveReadResult::PASSIVE_READ_RESULT_FAILURE: + case ReadResult::READ_RESULT_FAILURE: ESP_LOGW(TAG, "Startup failed, retrying in %" PRIu32 " ms", STARTUP_RETRY_MS); this->startup_wait_ms_ = STARTUP_RETRY_MS; - this->set_startup_phase_(StartupPhase::WAIT); + this->set_startup_phase_(StartupPhase::WAIT, now); return; } } } -void UFM01Component::loop_active_stream_() { - this->process_active_stream_(); - if (this->last_valid_frame_ms_ != 0 && millis() - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) { - ESP_LOGW(TAG, "Active stream stale, switching to passive polling"); - this->operating_mode_ = OperatingMode::PASSIVE_POLL; - this->passive_read_pending_ = false; - this->last_poll_ms_ = 0; - this->status_set_warning("UFM-01 passive poll"); - } +void UFM01Component::loop_active_stream_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + + this->process_active_stream_(now); + + if (this->last_valid_frame_ms_ != 0 && now - this->last_valid_frame_ms_ > ACTIVE_STALE_MS) + this->enter_passive_from_stale_(now); } -void UFM01Component::loop_passive_poll_() { +void UFM01Component::loop_passive_poll_(uint32_t now) { +#ifdef USE_UFM01_SOFTWARE_VERSION + if (this->service_software_version_(now)) + return; +#endif + if (this->passive_read_pending_) { - const PassiveReadResult result = this->continue_passive_read_(); - if (result == PassiveReadResult::PASSIVE_READ_RESULT_PENDING) + const ReadResult result = this->continue_passive_read_(now); + if (result == ReadResult::READ_RESULT_PENDING) return; this->passive_read_pending_ = false; - if (result == PassiveReadResult::PASSIVE_READ_RESULT_FAILURE) - this->status_set_warning("UFM-01 passive poll failed"); + this->note_passive_poll_result_(result == ReadResult::READ_RESULT_SUCCESS, now); return; } - if (this->process_active_stream_()) { - this->enter_active_stream_("resumed"); + if (this->process_active_stream_(now)) { + this->enter_active_stream_(LOG_STR("resumed"), now); return; } - if (millis() - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { - this->last_poll_ms_ = millis(); - this->start_passive_read_(); + if (now - this->last_poll_ms_ >= PASSIVE_POLL_INTERVAL_MS) { + this->last_poll_ms_ = now; + this->start_passive_read_(now); this->passive_read_pending_ = true; } } void UFM01Component::loop() { + const uint32_t now = App.get_loop_component_start_time(); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + this->loop_pending_clear_action_(now); + if (this->clear_pending_ && this->pending_clear_sent_) + return; +#endif switch (this->operating_mode_) { case OperatingMode::STARTUP: - this->loop_startup_(); + this->loop_startup_(now); return; case OperatingMode::ACTIVE_STREAM: - this->loop_active_stream_(); + this->loop_active_stream_(now); return; case OperatingMode::PASSIVE_POLL: - this->loop_passive_poll_(); + this->loop_passive_poll_(now); return; } } diff --git a/esphome/components/ufm01/ufm01.h b/esphome/components/ufm01/ufm01.h index 0a39dcc9af..cf73daa5fa 100644 --- a/esphome/components/ufm01/ufm01.h +++ b/esphome/components/ufm01/ufm01.h @@ -8,6 +8,9 @@ #ifdef USE_SENSOR #include "esphome/components/sensor/sensor.h" #endif +#ifdef USE_TEXT_SENSOR +#include "esphome/components/text_sensor/text_sensor.h" +#endif #include "esphome/components/uart/uart.h" #include @@ -23,6 +26,17 @@ class TestableUFM01; static constexpr size_t FRAME_SIZE = 32; static constexpr size_t PASSIVE_FRAME_SIZE = 23; +static constexpr size_t PASSIVE_FRAME_WITH_ID_SIZE = 39; +#ifdef USE_UFM01_METER_ID +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_WITH_ID_SIZE; +#else +static constexpr size_t PASSIVE_FRAME_MAX_SIZE = PASSIVE_FRAME_SIZE; +#endif +static constexpr size_t SOFTWARE_VERSION_RESPONSE_SIZE = 7; +static constexpr size_t DEVICE_ID_LENGTH = 5; +static constexpr size_t DEVICE_ID_STRING_LENGTH = 10; +static constexpr size_t SOFTWARE_VERSION_LENGTH = 4; +static constexpr size_t SOFTWARE_VERSION_STRING_LENGTH = 8; enum class OperatingMode : uint8_t { STARTUP = 0, @@ -31,18 +45,22 @@ enum class OperatingMode : uint8_t { }; enum class StartupPhase : uint8_t { - WAIT = 0, - RESET_WAIT_ACK = 1, - RESET_RETRY_WAIT = 2, - POST_RESET_WAIT = 3, - ACTIVE_WAIT_FRAME = 4, - PASSIVE_WAIT_REPLY = 5, + WAIT, + RESET_WAIT_ACK, + RESET_RETRY_WAIT, + POST_RESET_WAIT, +#ifdef USE_UFM01_SOFTWARE_VERSION + SOFTWARE_VERSION_WAIT_REPLY, +#endif + ACTIVE_WAIT_FRAME, + SET_PASSIVE_WAIT_ACK, + PASSIVE_WAIT_REPLY, }; -enum class PassiveReadResult : uint8_t { - PASSIVE_READ_RESULT_PENDING = 0, - PASSIVE_READ_RESULT_SUCCESS = 1, - PASSIVE_READ_RESULT_FAILURE = 2, +enum class ReadResult : uint8_t { + READ_RESULT_PENDING = 0, + READ_RESULT_SUCCESS = 1, + READ_RESULT_FAILURE = 2, }; class UFM01Component : public uart::UARTDevice, public Component { @@ -59,6 +77,13 @@ class UFM01Component : public uart::UARTDevice, public Component { SUB_BINARY_SENSOR(flow_rate_out_of_range) #endif +#ifdef USE_UFM01_METER_ID + SUB_TEXT_SENSOR(meter_id) +#endif +#ifdef USE_UFM01_SOFTWARE_VERSION + SUB_TEXT_SENSOR(software_version) +#endif + public: void setup() override; @@ -66,43 +91,85 @@ class UFM01Component : public uart::UARTDevice, public Component { void loop() override; +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + /// Queue a clear of the accumulated flow; sent once the UART is idle, a request while one is pending joins it + void request_clear_accumulated_flow(); +#endif + float get_setup_priority() const override; - protected: - bool clear_accumulated_flow_(); - bool set_active_mode_(); - bool reset_device_(); - private: - bool send_command_(const std::array &command); +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool can_start_clear_action_() const; + void loop_pending_clear_action_(uint32_t now); + void finish_pending_clear_(); +#endif void send_command_no_wait_(const std::array &command); bool consume_ack_(); void flush_rx_(); - bool process_active_stream_(); - void on_active_frame_(uint8_t data[FRAME_SIZE]); + bool process_active_stream_(uint32_t now); + void on_active_frame_(uint8_t data[FRAME_SIZE], uint32_t now); +#ifdef USE_UFM01_METER_ID + void publish_meter_id_from_frame_(const uint8_t data[FRAME_SIZE]); +#endif + void publish_stale_flow_and_temperature_(); +#ifdef USE_UFM01_SOFTWARE_VERSION + void start_software_version_read_(uint32_t now); + ReadResult continue_software_version_read_(uint32_t now); + void finish_software_version_read_(ReadResult result, uint32_t now); + bool service_software_version_(uint32_t now); +#endif - void loop_startup_(); - void loop_active_stream_(); - void loop_passive_poll_(); - void set_startup_phase_(StartupPhase phase); - void enter_active_stream_(const char *reason); - void start_passive_read_(); - PassiveReadResult continue_passive_read_(); + void loop_startup_(uint32_t now); + void loop_active_stream_(uint32_t now); + void loop_passive_poll_(uint32_t now); + void set_mode_(OperatingMode mode, uint32_t now); + void set_startup_phase_(StartupPhase phase, uint32_t now); + void send_reset_(const LogString *status, uint32_t now); + void enter_active_stream_(const LogString *reason, uint32_t now); + void enter_passive_from_stale_(uint32_t now); + void restart_startup_(const LogString *reason, uint32_t now); + void start_passive_read_(uint32_t now); + ReadResult continue_passive_read_(uint32_t now); + void note_passive_poll_result_(bool success, uint32_t now); + + uint32_t phase_start_ms_{0}; + uint32_t startup_wait_ms_{0}; + uint32_t last_valid_frame_ms_{0}; + uint32_t last_poll_ms_{0}; + // Shared by the passive read and the software version read, which never overlap + uint32_t passive_start_ms_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + uint32_t last_software_version_attempt_ms_{0}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + uint32_t pending_clear_start_ms_{0}; +#endif + int32_t read_index_{0}; OperatingMode operating_mode_{OperatingMode::STARTUP}; StartupPhase startup_phase_{StartupPhase::WAIT}; - uint32_t phase_start_ms_{0}; - uint32_t startup_wait_ms_{0}; bool reset_retried_{false}; - uint32_t last_valid_frame_ms_{0}; - uint32_t last_poll_ms_{0}; - bool passive_read_pending_{false}; - uint32_t passive_start_ms_{0}; - size_t passive_index_{0}; - uint8_t passive_frame_[PASSIVE_FRAME_SIZE]; + uint8_t consecutive_passive_failures_{0}; + uint8_t passive_index_{0}; + uint8_t passive_expected_size_{PASSIVE_FRAME_SIZE}; + uint8_t passive_start_byte_2_{0}; +#ifdef USE_UFM01_SOFTWARE_VERSION + bool software_version_published_{false}; + bool software_version_read_pending_{false}; + uint8_t software_version_failures_{0}; +#endif +#ifdef USE_UFM01_METER_ID + bool meter_id_published_{false}; + bool meter_id_invalid_logged_{false}; +#endif +#ifdef USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION + bool clear_pending_{false}; + bool pending_clear_sent_{false}; +#endif - int32_t read_index_ = 0; + uint8_t passive_frame_[PASSIVE_FRAME_MAX_SIZE]; uint8_t data_[FRAME_SIZE]; friend class testing::TestableUFM01; diff --git a/esphome/components/uln2003/__init__.py b/esphome/components/uln2003/__init__.py index e69de29bb2..6f2260af57 100644 --- a/esphome/components/uln2003/__init__.py +++ b/esphome/components/uln2003/__init__.py @@ -0,0 +1 @@ +DOMAIN = "uln2003" diff --git a/esphome/components/uln2003/uln2003.cpp b/esphome/components/uln2003/uln2003.cpp index a2244eadaa..64db28a530 100644 --- a/esphome/components/uln2003/uln2003.cpp +++ b/esphome/components/uln2003/uln2003.cpp @@ -3,7 +3,7 @@ namespace esphome::uln2003 { -static const char *const TAG = "uln2003.stepper"; +ESPHOME_LOG_TAG(TAG, "uln2003.stepper"); static const LogString *step_mode_to_log_string(ULN2003StepMode mode) { switch (mode) { diff --git a/esphome/components/ultrasonic/__init__.py b/esphome/components/ultrasonic/__init__.py index 3bca12bffc..cdc67e7813 100644 --- a/esphome/components/ultrasonic/__init__.py +++ b/esphome/components/ultrasonic/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@swoboda1337", "@ssieb"] +DOMAIN = "ultrasonic" diff --git a/esphome/components/ultrasonic/ultrasonic_sensor.cpp b/esphome/components/ultrasonic/ultrasonic_sensor.cpp index a354b198b4..039d08eaae 100644 --- a/esphome/components/ultrasonic/ultrasonic_sensor.cpp +++ b/esphome/components/ultrasonic/ultrasonic_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::ultrasonic { -static const char *const TAG = "ultrasonic.sensor"; +ESPHOME_LOG_TAG(TAG, "ultrasonic.sensor"); static constexpr uint32_t DEBOUNCE_US = 50; // Ignore edges within 50us of each other (noise filtering) static constexpr uint32_t START_DELAY_US = 100; // Ignore edges within 100us of trigger (filters bleed-through) diff --git a/esphome/components/unicode/__init__.py b/esphome/components/unicode/__init__.py new file mode 100644 index 0000000000..c5464cbe6c --- /dev/null +++ b/esphome/components/unicode/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@esphome/core"] +DOMAIN = "unicode" 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 818358c48a..e455a4f768 100644 --- a/esphome/components/update/__init__.py +++ b/esphome/components/update/__init__.py @@ -14,17 +14,18 @@ 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"] +DOMAIN = "update" IS_PLATFORM_COMPONENT = True update_ns = cg.esphome_ns.namespace("update") @@ -32,13 +33,6 @@ UpdateEntity = update_ns.class_("UpdateEntity", cg.EntityBase) UpdateInfo = update_ns.struct("UpdateInfo") -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, @@ -144,42 +138,17 @@ automation.register_apply_action( ) -@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 3d303fdb63..0000000000 --- a/esphome/components/update/automation.h +++ /dev/null @@ -1,19 +0,0 @@ -#pragma once - -#include "update_entity.h" - -#include "esphome/core/automation.h" - -namespace esphome::update { - -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.cpp b/esphome/components/update/update_entity.cpp index b0d4c01cc9..33c33ccb62 100644 --- a/esphome/components/update/update_entity.cpp +++ b/esphome/components/update/update_entity.cpp @@ -6,7 +6,7 @@ namespace esphome::update { -static const char *const TAG = "update"; +ESPHOME_LOG_TAG(TAG, "update"); // Update state strings indexed by UpdateState enum (0-3): UNKNOWN, NO UPDATE, UPDATE AVAILABLE, INSTALLING PROGMEM_STRING_TABLE(UpdateStateStrings, "UNKNOWN", "NO UPDATE", "UPDATE AVAILABLE", "INSTALLING"); diff --git a/esphome/components/uponor_smatrix/__init__.py b/esphome/components/uponor_smatrix/__init__.py index ba686dc22a..5dc32259ea 100644 --- a/esphome/components/uponor_smatrix/__init__.py +++ b/esphome/components/uponor_smatrix/__init__.py @@ -6,6 +6,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@kroimon"] +DOMAIN = "uponor_smatrix" DEPENDENCIES = ["uart"] diff --git a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp index 0400888511..7b6717c682 100644 --- a/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp +++ b/esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp @@ -7,7 +7,7 @@ namespace esphome::uponor_smatrix { -static const char *const TAG = "uponor_smatrix.climate"; +ESPHOME_LOG_TAG(TAG, "uponor_smatrix.climate"); void UponorSmatrixClimate::dump_config() { LOG_CLIMATE("", "Uponor Smatrix Climate", this); 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/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp index 97e9c27570..9b5f7df87a 100644 --- a/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp +++ b/esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::uponor_smatrix { -static const char *const TAG = "uponor_smatrix.sensor"; +ESPHOME_LOG_TAG(TAG, "uponor_smatrix.sensor"); void UponorSmatrixSensor::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/uponor_smatrix/uponor_smatrix.cpp b/esphome/components/uponor_smatrix/uponor_smatrix.cpp index 74974548af..fd6eda73a0 100644 --- a/esphome/components/uponor_smatrix/uponor_smatrix.cpp +++ b/esphome/components/uponor_smatrix/uponor_smatrix.cpp @@ -7,7 +7,7 @@ namespace esphome::uponor_smatrix { -static const char *const TAG = "uponor_smatrix"; +ESPHOME_LOG_TAG(TAG, "uponor_smatrix"); // Maximum bytes to log in verbose hex output static constexpr size_t UPONOR_MAX_LOG_BYTES = 36; diff --git a/esphome/components/uptime/__init__.py b/esphome/components/uptime/__init__.py index e69de29bb2..74eb20c656 100644 --- a/esphome/components/uptime/__init__.py +++ b/esphome/components/uptime/__init__.py @@ -0,0 +1 @@ +DOMAIN = "uptime" diff --git a/esphome/components/uptime/sensor/__init__.py b/esphome/components/uptime/sensor/__init__.py index a13d6cd7fd..04140ba431 100644 --- a/esphome/components/uptime/sensor/__init__.py +++ b/esphome/components/uptime/sensor/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import sensor, time +from esphome.components.time import DOMAIN as TIME_DOMAIN from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -44,7 +45,8 @@ CONFIG_SCHEMA = cv.typed_schema( cv.Schema( { cv.GenerateID(CONF_TIME_ID): cv.All( - cv.requires_component("time"), cv.use_id(time.RealTimeClock) + cv.requires_component(TIME_DOMAIN), + cv.use_id(time.RealTimeClock), ), } ) diff --git a/esphome/components/uptime/sensor/uptime_seconds_sensor.cpp b/esphome/components/uptime/sensor/uptime_seconds_sensor.cpp index 552e4e5da4..c5dbf742a1 100644 --- a/esphome/components/uptime/sensor/uptime_seconds_sensor.cpp +++ b/esphome/components/uptime/sensor/uptime_seconds_sensor.cpp @@ -5,7 +5,7 @@ namespace esphome::uptime { -static const char *const TAG = "uptime.sensor"; +ESPHOME_LOG_TAG(TAG, "uptime.sensor"); void UptimeSecondsSensor::update() { const uint64_t uptime = millis_64(); diff --git a/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp b/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp index e8b8a21562..43c8d1cf75 100644 --- a/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp +++ b/esphome/components/uptime/sensor/uptime_timestamp_sensor.cpp @@ -8,7 +8,7 @@ namespace esphome::uptime { -static const char *const TAG = "uptime.sensor"; +ESPHOME_LOG_TAG(TAG, "uptime.sensor"); void UptimeTimestampSensor::setup() { this->time_->add_on_time_sync_callback([this]() { diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp index 7a56654804..33cde23b94 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp @@ -6,7 +6,7 @@ namespace esphome::uptime { -static const char *const TAG = "uptime.sensor"; +ESPHOME_LOG_TAG(TAG, "uptime.sensor"); static void append_unit(char *buf, size_t buf_size, size_t &pos, const char *separator, unsigned value, const char *label) { diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py index 8cd078ab49..08cd13e3d2 100644 --- a/esphome/components/usb_cdc_acm/__init__.py +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -15,6 +15,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] AUTO_LOAD = ["uart"] DEPENDENCIES = ["tinyusb"] +DOMAIN = "usb_cdc_acm" CONF_INTERFACES = "interfaces" @@ -28,7 +29,7 @@ USBCDCACMInstance = usb_cdc_acm_ns.class_( # Schema for individual CDC ACM interface instances INTERFACE_SCHEMA = cv.Schema( { - cv.GenerateID(): cv.declare_id(USBCDCACMInstance), + cv.GenerateID(): cv.All(cv.declare_id(USBCDCACMInstance), uart.mark_unclocked), } ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp index 454c049da3..f24339b4bf 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -6,7 +6,7 @@ #include "esphome/core/log.h" namespace esphome::usb_cdc_acm { -static const char *const TAG = "usb_cdc_acm"; +ESPHOME_LOG_TAG(TAG, "usb_cdc_acm"); // Global component instance for managing USB device USBCDCACMComponent *global_usb_cdc_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) 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 7aa7b46b7b..9ac94802e1 100644 --- a/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm_esp32.cpp @@ -17,7 +17,7 @@ namespace esphome::usb_cdc_acm { -static const char *const TAG = "usb_cdc_acm"; +ESPHOME_LOG_TAG(TAG, "usb_cdc_acm"); // Maximum bytes to log in very verbose hex output (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t USB_CDC_MAX_LOG_BYTES = 168; diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py index 4abcc3a449..317cec8236 100644 --- a/esphome/components/usb_host/__init__.py +++ b/esphome/components/usb_host/__init__.py @@ -1,4 +1,8 @@ +import itertools +import logging + import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( VARIANT_ESP32H4, VARIANT_ESP32P4, @@ -15,8 +19,12 @@ 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 +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["bytebuffer"] CODEOWNERS = ["@clydebarrow"] DEPENDENCIES = ["esp32"] @@ -26,11 +34,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 +65,62 @@ 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 _clients_overlap(first: ConfigType, second: ConfigType) -> bool: + # Two entries overlap when no field they both constrain tells them apart + for key, wildcard in _FILTER_WILDCARDS.items(): + a = first.get(key) + b = second.get(key) + if wildcard not in (a, b) and a != b: + return False + return True + + +def validate_usb_clients(configs: list[ConfigType]) -> list[ConfigType]: + for first, second in itertools.combinations(configs, 2): + if _clients_overlap(first, second): + raise cv.Invalid( + f"USB configs overlap: {first[CONF_ID]}, {second[CONF_ID]}" + ) + return configs + + +def _final_validate(config: ConfigType) -> ConfigType: + # Any two overlapping clients could otherwise open the same device + devices = config.get(CONF_DEVICES) or [] + uarts = fv.full_config.get().get("usb_uart") or [] + validate_usb_clients(devices) + validate_usb_clients(uarts) + # Remove before 2027.10.0, then pool devices and uarts in one validate_usb_clients call + for device, uart in itertools.product(devices, uarts): + if not _clients_overlap(device, uart): + continue + # The device matches nothing the usb_uart entry does not already claim + redundant = all( + uart.get(key) in (wildcard, device.get(key)) + for key, wildcard in _FILTER_WILDCARDS.items() + ) + _LOGGER.warning( + "usb_host device '%s' matches the same USB device as usb_uart '%s'; %s. " + "This will be an error in 2027.10.0", + device[CONF_ID], + uart[CONF_ID], + "remove it from usb_host devices" + if redundant + else "narrow its filter so it no longer matches that device", + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate def _set_max_packet_size(config: dict) -> dict: @@ -91,6 +164,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 5d0f8be165..0875966071 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,12 +1,19 @@ import esphome.codegen as cg from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant -from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent +from esphome.components.uart import ( + CONF_DEBUG_PREFIX, + CONF_FLUSH_TIMEOUT, + UARTComponent, + UARTParityOptions, + mark_unclocked, +) from esphome.components.usb_host import ( get_max_packet_size, 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,19 +22,29 @@ 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 AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] CODEOWNERS = ["@clydebarrow"] +DOMAIN = "usb_uart" usb_uart_ns = cg.esphome_ns.namespace("usb_uart") USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) -UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") + +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(DOMAIN) or [] + for channel in device[CONF_CHANNELS] + ) + + UART_PARITY_OPTIONS = { "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, @@ -64,6 +81,8 @@ class Type: self.vid = vid self.pid = pid self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) + # CDC ACM lives in usb_uart.cpp; each vendor driver has its own .cpp + self.driver = None if cls == "CdcAcm" else cls self._max_channels = max_channels self.baud_rate_required = baud_rate_required self.max_baud = max_baud @@ -121,6 +140,21 @@ uart_types = ( ), ) +_TYPES_BY_NAME = {it.name: it for it in uart_types} + + +def _driver_define(driver: str) -> str: + return f"USE_USB_UART_{driver}" + + +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + f"{it.driver.lower()}.cpp": _driver_define(it.driver) + for it in uart_types + if it.driver is not None + } +) + def channel_schema(type_: "Type") -> cv.Schema: schema = cv.Schema( @@ -129,7 +163,9 @@ def channel_schema(type_: "Type") -> cv.Schema: cv.ensure_list( cv.Schema( { - cv.GenerateID(): cv.declare_id(USBUartChannel), + cv.GenerateID(): cv.All( + cv.declare_id(USBUartChannel), mark_unclocked + ), cv.Optional(CONF_BUFFER_SIZE, default=256): cv.int_range( min=64, max=8192 ), @@ -211,6 +247,8 @@ async def to_code(config: list[ConfigType]) -> None: cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count) for device in config: + if (driver := _TYPES_BY_NAME[device[CONF_TYPE]].driver) is not None: + cg.add_define(_driver_define(driver)) var = await register_usb_client(device) # The C++ default is true; only emit the override if not device.get(CONF_CLAIM_COMM_INTERFACE, True): diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 00c5e0b069..ed5a034b82 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -132,15 +132,12 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo factor = 256 - factor; uint16_t value = 0xC0; - if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + if (channel->line_stop_bits_() == LineStopBits::LINE_STOP_BITS_2) value |= 4; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - break; - default: - value |= 8 | ((channel->parity_ - 1) << 4); - break; - } + // Parity enable, then 0 = odd, 1 = even, 2 = mark, 3 = space + const LineParity parity = channel->line_parity_(); + if (parity != LineParity::LINE_PARITY_NONE) + value |= 8 | ((static_cast(parity) - 1) << 4); value |= channel->data_bits_ - 5; value <<= 8; value |= 0x8C; diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5551abe1a1..e920da6c26 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -107,8 +107,8 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_); return true; case 1: { - uint16_t line_control = channel->stop_bits_; - line_control |= static_cast(channel->parity_) << 4; + uint16_t line_control = static_cast(channel->line_stop_bits_()); + line_control |= static_cast(channel->line_parity_()) << 4; line_control |= channel->data_bits_ << 8; ESP_LOGD(TAG, "Line control value 0x%X", line_control); this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_); diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index fcebf0fbd9..e10da78aaf 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -362,34 +362,8 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b } case 2: { // set line properties (data bits / parity / stop bits) uint16_t value = channel->data_bits_; - switch (channel->parity_) { - case UART_CONFIG_PARITY_NONE: - value |= (0x00 << 8); - break; - case UART_CONFIG_PARITY_ODD: - value |= (0x01 << 8); - break; - case UART_CONFIG_PARITY_EVEN: - value |= (0x02 << 8); - break; - case UART_CONFIG_PARITY_MARK: - value |= (0x03 << 8); - break; - case UART_CONFIG_PARITY_SPACE: - value |= (0x04 << 8); - break; - } - switch (channel->stop_bits_) { - default: // 1 bit - value |= (0x00 << 11); - break; - case UART_CONFIG_STOP_BITS_1_5: - value |= (0x01 << 11); - break; - case UART_CONFIG_STOP_BITS_2: - value |= (0x02 << 11); - break; - } + value |= static_cast(channel->line_parity_()) << 8; + value |= static_cast(channel->line_stop_bits_()) << 11; value |= (0x00 << 14); this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, 0x04, value, channel->cdc_dev_.bulk_interface_number + 1); diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index db177fd308..36107c093c 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -261,34 +261,9 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b encode_baud_divisor(line_coding, baud); } - // Stop bits: 0=1, 1=1.5, 2=2 - switch (channel->get_stop_bits()) { - case 2: - line_coding[4] = 2; - break; - default: - line_coding[4] = 0; - break; - } + line_coding[4] = static_cast(channel->line_stop_bits_()); - // Parity: 0=none, 1=odd, 2=even, 3=mark, 4=space - switch (channel->parity_) { - case UART_CONFIG_PARITY_ODD: - line_coding[5] = 1; - break; - case UART_CONFIG_PARITY_EVEN: - line_coding[5] = 2; - break; - case UART_CONFIG_PARITY_MARK: - line_coding[5] = 3; - break; - case UART_CONFIG_PARITY_SPACE: - line_coding[5] = 4; - break; - default: - line_coding[5] = 0; - break; - } + line_coding[5] = static_cast(channel->line_parity_()); // Data bits line_coding[6] = channel->get_data_bits(); diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index 3113f695f6..f61642566c 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) { @@ -272,9 +270,10 @@ void USBUartComponent::dump_config() { " Flush Timeout: %" PRIu32 " ms\n" " Debug: %s\n" " Dummy receiver: %s", - channel->index_, channel->baud_rate_, channel->data_bits_, PARITY_NAMES[channel->parity_], - STOP_BITS_NAMES[channel->stop_bits_], channel->flush_timeout_ms_, YESNO(channel->debug_), - YESNO(channel->dummy_receiver_)); + channel->index_, channel->baud_rate_, channel->data_bits_, + PARITY_NAMES[static_cast(channel->line_parity_())], + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], channel->flush_timeout_ms_, + YESNO(channel->debug_), YESNO(channel->dummy_receiver_)); } } void USBUartComponent::start_input(USBUartChannelBase *channel) { @@ -463,10 +462,12 @@ void USBUartTypeCdcAcm::on_connected() { void USBUartTypeCdcAcm::on_disconnected() { for (auto *channel : this->channels_) { - if (channel->cdc_dev_.in_ep != nullptr) { - usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); - usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); - } + // Not set up for this device: it was rejected before on_connected() ran, or it has + // fewer ports than there are channels. Nothing was claimed for it. + if (channel->cdc_dev_.in_ep == nullptr) + continue; + usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); + usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); if (channel->cdc_dev_.out_ep != nullptr) { usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); @@ -494,6 +495,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(); } @@ -513,14 +516,17 @@ bool USBUartTypeCdcAcm::config_step(USBUartChannelBase *channel, uint8_t step, b // sends RSTACK. uint32_t baud = channel->baud_rate_; std::vector line_coding = { - static_cast(baud & 0xFF), static_cast((baud >> 8) & 0xFF), - static_cast((baud >> 16) & 0xFF), static_cast((baud >> 24) & 0xFF), - static_cast(channel->stop_bits_), // bCharFormat: 0=1stop, 1=1.5stop, 2=2stop - static_cast(channel->parity_), // bParityType: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space - static_cast(channel->data_bits_), // bDataBits + static_cast(baud & 0xFF), + static_cast((baud >> 8) & 0xFF), + static_cast((baud >> 16) & 0xFF), + static_cast((baud >> 24) & 0xFF), + static_cast(channel->line_stop_bits_()), // bCharFormat + static_cast(channel->line_parity_()), // bParityType + static_cast(channel->data_bits_), // bDataBits }; - ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%u parity=%u data=%u", (unsigned) baud, channel->stop_bits_, - (unsigned) channel->parity_, channel->data_bits_); + ESP_LOGD(TAG, "SET_LINE_CODING: baud=%u stop=%s parity=%s data=%u", (unsigned) baud, + STOP_BITS_NAMES[static_cast(channel->line_stop_bits_())], + PARITY_NAMES[static_cast(channel->line_parity_())], channel->data_bits_); this->config_transfer_(CDC_REQUEST_TYPE, CDC_SET_LINE_CODING, 0, channel->cdc_dev_.interrupt_interface_number, line_coding); return true; @@ -660,6 +666,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. @@ -671,6 +679,32 @@ bool USBUartComponent::run_config_machine_() { return true; } +LineStopBits USBUartChannelBase::line_stop_bits_() const { + switch (this->stop_bits_) { + case UART_CONFIG_STOP_BITS_1_5: + return LineStopBits::LINE_STOP_BITS_1_5; + case UART_CONFIG_STOP_BITS_2: + return LineStopBits::LINE_STOP_BITS_2; + default: + return LineStopBits::LINE_STOP_BITS_1; + } +} + +LineParity USBUartChannelBase::line_parity_() const { + switch (this->parity_) { + case uart::UART_CONFIG_PARITY_ODD: + return LineParity::LINE_PARITY_ODD; + case uart::UART_CONFIG_PARITY_EVEN: + return LineParity::LINE_PARITY_EVEN; + case uart::UART_CONFIG_PARITY_MARK: + return LineParity::LINE_PARITY_MARK; + case uart::UART_CONFIG_PARITY_SPACE: + return LineParity::LINE_PARITY_SPACE; + default: + return LineParity::LINE_PARITY_NONE; + } +} + void USBUartChannelBase::load_settings(bool /*dump_config*/) { // The per-channel control transfers already log their values at debug level. this->parent_->apply_channel_settings(this); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index 22563209da..4e52fb60e2 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}; }; @@ -70,21 +71,33 @@ enum CH34xChipType : uint8_t { CHIP_UNKNOWN = 0xFF, }; -enum UARTParityOptions { - UART_CONFIG_PARITY_NONE = 0, - UART_CONFIG_PARITY_ODD, - UART_CONFIG_PARITY_EVEN, - UART_CONFIG_PARITY_MARK, - UART_CONFIG_PARITY_SPACE, -}; - +// 1 and 2 are the stop bit count the uart component uses, so a count set through the +// UARTComponent API (as serial_proxy does) means the same here. 1.5 has no count. enum UARTStopBitsOptions { - UART_CONFIG_STOP_BITS_1 = 0, - UART_CONFIG_STOP_BITS_1_5, - UART_CONFIG_STOP_BITS_2, + UART_CONFIG_STOP_BITS_1 = 1, + UART_CONFIG_STOP_BITS_2 = 2, + UART_CONFIG_STOP_BITS_1_5 = 3, }; +// Stop bits and parity as adapters take them on the wire: CDC SET_LINE_CODING, and the +// CP210x, FTDI and PL2303 requests, all use these values +enum class LineStopBits : uint8_t { + LINE_STOP_BITS_1 = 0, + LINE_STOP_BITS_1_5 = 1, + LINE_STOP_BITS_2 = 2, +}; + +enum class LineParity : uint8_t { + LINE_PARITY_NONE = 0, + LINE_PARITY_ODD = 1, + LINE_PARITY_EVEN = 2, + LINE_PARITY_MARK = 3, + LINE_PARITY_SPACE = 4, +}; + +// Indexed by LineParity static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"}; +// Indexed by LineStopBits static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; class RingBuffer { @@ -155,7 +168,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedparity_ = parity; } 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,10 +179,19 @@ 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) {} void check_logger_conflict() override {} + LineStopBits line_stop_bits_() const; + LineParity line_parity_() const; // Larger structures first (8+ bytes) RingBuffer input_buffer_; LockFreeQueue output_queue_; @@ -182,7 +203,6 @@ class USBUartChannelBase : public uart::UARTComponent, public Parented input_started_{true}; @@ -252,6 +272,10 @@ class USBUartComponent : public usb_host::USBClient { // (e.g. CH34x chip detection). Same contract as config_step_(). Default: no steps. virtual bool config_device_step(uint8_t step, bool ok, const uint8_t *response) { return false; } + // The device is only usable once the config machine has applied every channel's line + // settings, so the connected report waits for run_config_machine_() to finish the init + bool reports_connection_itself() const override { return true; } + std::vector channels_{}; // Config state machine diff --git a/esphome/components/valve/__init__.py b/esphome/components/valve/__init__.py index 692759f3e4..41e25d63b1 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 @@ -15,7 +15,6 @@ from esphome.const import ( CONF_POSITION_STATE_TOPIC, CONF_STATE, CONF_STOP, - CONF_TRIGGER_ID, CONF_WEB_SERVER, DEVICE_CLASS_EMPTY, DEVICE_CLASS_GAS, @@ -33,6 +32,7 @@ from esphome.cpp_generator import MockObjClass IS_PLATFORM_COMPONENT = True CODEOWNERS = ["@esphome/core"] +DOMAIN = "valve" DEVICE_CLASSES = [ DEVICE_CLASS_EMPTY, @@ -61,23 +61,18 @@ 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) -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()) -ValveClosedTrigger = valve_ns.class_( - "ValveClosedTrigger", automation.Trigger.template() -) - CONF_ON_CLOSED = "on_closed" +# The state callback carries nothing; each automation fires only in its end state. +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_OPEN, "add_on_state_callback", when="{parent}->is_fully_open()" + ), + automation.CallbackAutomation( + CONF_ON_CLOSED, "add_on_state_callback", when="{parent}->is_fully_closed()" + ), +) + _VALVE_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) .extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA) @@ -94,16 +89,8 @@ _VALVE_SCHEMA = ( cv.Optional(CONF_POSITION_STATE_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), - cv.Optional(CONF_ON_OPEN): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValveOpenTrigger), - } - ), - cv.Optional(CONF_ON_CLOSED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ValveClosedTrigger), - } - ), + cv.Optional(CONF_ON_OPEN): automation.validate_automation({}), + cv.Optional(CONF_ON_CLOSED): automation.validate_automation({}), } ) ) @@ -139,12 +126,7 @@ def valve_schema( async def _setup_valve_core(var, config): setup_device_class(config) - for conf in config.get(CONF_ON_OPEN, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_CLOSED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) if mqtt_id_config := config.get(CONF_MQTT_ID): mqtt_ = cg.new_Pvariable(mqtt_id_config, var) @@ -182,36 +164,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( diff --git a/esphome/components/valve/automation.h b/esphome/components/valve/automation.h deleted file mode 100644 index 9fdb7665b3..0000000000 --- a/esphome/components/valve/automation.h +++ /dev/null @@ -1,95 +0,0 @@ -#pragma once - -#include "esphome/core/automation.h" -#include "esphome/core/component.h" -#include "valve.h" - -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_; -}; - -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) { - a_valve->add_on_state_callback([this]() { - if (this->valve_->is_fully_open()) { - this->trigger(); - } - }); - } - - protected: - Valve *valve_; -}; - -class ValveClosedTrigger final : public Trigger<> { - public: - ValveClosedTrigger(Valve *a_valve) : valve_(a_valve) { - a_valve->add_on_state_callback([this]() { - if (this->valve_->is_fully_closed()) { - this->trigger(); - } - }); - } - - protected: - Valve *valve_; -}; - -} // namespace esphome::valve diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index d8fb18b1b7..a5ad9817df 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -8,7 +8,7 @@ namespace esphome::valve { -static const char *const TAG = "valve"; +ESPHOME_LOG_TAG(TAG, "valve"); const float VALVE_OPEN = 1.0f; const float VALVE_CLOSED = 0.0f; diff --git a/esphome/components/vbus/__init__.py b/esphome/components/vbus/__init__.py index fd54658912..533acfbbb9 100644 --- a/esphome/components/vbus/__init__.py +++ b/esphome/components/vbus/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] +DOMAIN = "vbus" DEPENDENCIES = ["uart"] 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/binary_sensor/vbus_binary_sensor.cpp b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp index ddb6b53068..60822c5283 100644 --- a/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp +++ b/esphome/components/vbus/binary_sensor/vbus_binary_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::vbus { -static const char *const TAG = "vbus.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "vbus.binary_sensor"); void DeltaSolBSPlusBSensor::dump_config() { ESP_LOGCONFIG(TAG, "Deltasol BS Plus:"); 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/vbus/sensor/vbus_sensor.cpp b/esphome/components/vbus/sensor/vbus_sensor.cpp index 773e1435e2..5c111c2294 100644 --- a/esphome/components/vbus/sensor/vbus_sensor.cpp +++ b/esphome/components/vbus/sensor/vbus_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::vbus { -static const char *const TAG = "vbus.sensor"; +ESPHOME_LOG_TAG(TAG, "vbus.sensor"); static inline uint16_t get_u16(std::vector &message, int start) { return (message[start + 1] << 8) + message[start]; diff --git a/esphome/components/vbus/vbus.cpp b/esphome/components/vbus/vbus.cpp index 080567e7f9..6b6a3393ee 100644 --- a/esphome/components/vbus/vbus.cpp +++ b/esphome/components/vbus/vbus.cpp @@ -6,7 +6,7 @@ namespace esphome::vbus { -static const char *const TAG = "vbus"; +ESPHOME_LOG_TAG(TAG, "vbus"); // Maximum bytes to log in verbose hex output (16 frames * 4 bytes = 64 bytes typical) static constexpr size_t VBUS_MAX_LOG_BYTES = 64; diff --git a/esphome/components/veml3235/__init__.py b/esphome/components/veml3235/__init__.py index e69de29bb2..57c95f46ef 100644 --- a/esphome/components/veml3235/__init__.py +++ b/esphome/components/veml3235/__init__.py @@ -0,0 +1 @@ +DOMAIN = "veml3235" diff --git a/esphome/components/veml3235/veml3235.cpp b/esphome/components/veml3235/veml3235.cpp index b3170469b9..3a85669678 100644 --- a/esphome/components/veml3235/veml3235.cpp +++ b/esphome/components/veml3235/veml3235.cpp @@ -4,7 +4,9 @@ namespace esphome::veml3235 { -static const char *const TAG = "veml3235.sensor"; +ESPHOME_LOG_TAG(TAG, "veml3235.sensor"); + +static constexpr uint32_t REREAD_TIMEOUT_ID = 0; // ADC counts at or above this value (98% of full scale) are treated as clipped: the true light level cannot // be estimated from such a reading, so auto-gain restarts from minimum sensitivity instead @@ -71,7 +73,7 @@ void VEML3235Sensor::read_and_publish_(uint8_t adjustments_left) { const uint32_t old_integration_time_ms = this->integration_time_ms_(); if (this->adjust_sensitivity_(als_counts)) { const uint32_t wait_ms = old_integration_time_ms + 2 * this->integration_time_ms_(); - this->set_timeout("reread", wait_ms, + this->set_timeout(REREAD_TIMEOUT_ID, wait_ms, [this, adjustments_left]() { this->read_and_publish_(adjustments_left - 1); }); return; } diff --git a/esphome/components/veml7700/__init__.py b/esphome/components/veml7700/__init__.py index dd06cfffea..9c8f1442d7 100644 --- a/esphome/components/veml7700/__init__.py +++ b/esphome/components/veml7700/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@latonita"] +DOMAIN = "veml7700" diff --git a/esphome/components/veml7700/sensor.py b/esphome/components/veml7700/sensor.py index 02820505ed..9ed98fc4df 100644 --- a/esphome/components/veml7700/sensor.py +++ b/esphome/components/veml7700/sensor.py @@ -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/veml7700/veml7700.cpp b/esphome/components/veml7700/veml7700.cpp index 6c609f4fe7..236b4bb2a9 100644 --- a/esphome/components/veml7700/veml7700.cpp +++ b/esphome/components/veml7700/veml7700.cpp @@ -5,7 +5,7 @@ namespace esphome::veml7700 { -static const char *const TAG = "veml7700"; +ESPHOME_LOG_TAG(TAG, "veml7700"); static const size_t VEML_REG_SIZE = 2; static float reduce_to_zero(float a, float b) { return (a > b) ? (a - b) : 0; } diff --git a/esphome/components/version/__init__.py b/esphome/components/version/__init__.py index f70ffa9520..f9c04b2596 100644 --- a/esphome/components/version/__init__.py +++ b/esphome/components/version/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@esphome/core"] +DOMAIN = "version" 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.cpp b/esphome/components/version/version_text_sensor.cpp index 15e6b0d088..1be7920a4c 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -7,7 +7,7 @@ namespace esphome::version { -static const char *const TAG = "version.text_sensor"; +ESPHOME_LOG_TAG(TAG, "version.text_sensor"); void VersionTextSensor::setup() { static const char HASH_PREFIX[] PROGMEM = ESPHOME_VERSION " (config hash 0x"; diff --git a/esphome/components/version/version_text_sensor.h b/esphome/components/version/version_text_sensor.h index 7ff6ac4d35..8537435079 100644 --- a/esphome/components/version/version_text_sensor.h +++ b/esphome/components/version/version_text_sensor.h @@ -15,8 +15,8 @@ class VersionTextSensor final : public text_sensor::TextSensor, public Component void dump_config() override; protected: - bool hide_hash_{false}; - bool hide_timestamp_{false}; + bool hide_hash_{false}; // Must match the setter check in text_sensor.py + bool hide_timestamp_{false}; // Must match the setter check in text_sensor.py }; } // namespace esphome::version diff --git a/esphome/components/vl53l0x/__init__.py b/esphome/components/vl53l0x/__init__.py index e69de29bb2..3363ba9961 100644 --- a/esphome/components/vl53l0x/__init__.py +++ b/esphome/components/vl53l0x/__init__.py @@ -0,0 +1 @@ +DOMAIN = "vl53l0x" diff --git a/esphome/components/vl53l0x/vl53l0x_sensor.cpp b/esphome/components/vl53l0x/vl53l0x_sensor.cpp index 49eb3d00a1..2e3d1c8529 100644 --- a/esphome/components/vl53l0x/vl53l0x_sensor.cpp +++ b/esphome/components/vl53l0x/vl53l0x_sensor.cpp @@ -14,7 +14,7 @@ namespace esphome::vl53l0x { -static const char *const TAG = "vl53l0x"; +ESPHOME_LOG_TAG(TAG, "vl53l0x"); std::list VL53L0XSensor::vl53_sensors; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) bool VL53L0XSensor::enable_pin_setup_complete = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -265,7 +265,7 @@ void VL53L0XSensor::setup() { void VL53L0XSensor::update() { if (this->initiated_read_ || this->waiting_for_interrupt_) { this->publish_state(NAN); - this->status_momentary_warning("update", 5000); + this->status_momentary_warning(5000); ESP_LOGW(TAG, "%s - update called before prior reading complete - initiated:%d waiting_for_interrupt:%d", this->name_.c_str(), this->initiated_read_, this->waiting_for_interrupt_); return; diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index d66ef43fdc..d306947ec1 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_condition import esphome.codegen as cg from esphome.components import media_player, micro_wake_word, microphone, speaker import esphome.config_validation as cv @@ -14,14 +13,13 @@ 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"] DEPENDENCIES = ["api", "microphone"] CODEOWNERS = ["@jesserockz", "@kahrendt"] +DOMAIN = "voice_assistant" CONF_ON_END = "on_end" CONF_ON_INTENT_END = "on_intent_end" @@ -62,12 +60,6 @@ MAX_MICROPHONE_SOURCES = 2 voice_assistant_ns = cg.esphome_ns.namespace("voice_assistant") VoiceAssistant = voice_assistant_ns.class_("VoiceAssistant", cg.Component) -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") @@ -424,29 +416,14 @@ automation.register_apply_action( ) -@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.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index dba9b925d0..039410abdb 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -12,7 +12,12 @@ namespace esphome::voice_assistant { -static const char *const TAG = "voice_assistant"; +ESPHOME_LOG_TAG(TAG, "voice_assistant"); + +static constexpr uint32_t RESET_CONVERSATION_TIMEOUT_ID = 0; +static constexpr uint32_t PLAYBACK_TIMEOUT_ID = 1; +static constexpr uint32_t SPEAKER_IDLE_TIMEOUT_ID = 2; +static constexpr uint32_t TIMER_TICK_INTERVAL_ID = 3; #ifdef SAMPLE_RATE_HZ #undef SAMPLE_RATE_HZ @@ -370,7 +375,7 @@ void VoiceAssistant::loop() { break; } this->set_state_(State::STARTING_PIPELINE); - this->set_timeout("reset-conversation_id", this->conversation_timeout_, + this->set_timeout(RESET_CONVERSATION_TIMEOUT_ID, this->conversation_timeout_, [this]() { this->reset_conversation_id(); }); break; } @@ -460,10 +465,10 @@ void VoiceAssistant::loop() { if (this->speaker_bytes_received_ > RECEIVE_SIZE * 4 || end_of_stream) this->write_speaker_(); if (this->wait_for_stream_end_) { - this->cancel_timeout("playing"); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); if (end_of_stream) { ESP_LOGD(TAG, "End of audio stream received"); - this->cancel_timeout("speaker-timeout"); + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); } break; // We dont want to timeout here as the STREAM_END event will take care of that. @@ -477,7 +482,7 @@ void VoiceAssistant::loop() { if (this->media_player_response_state_ == MediaPlayerResponseState::FINISHED) { this->media_player_response_state_ = MediaPlayerResponseState::IDLE; - this->cancel_timeout("playing"); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); ESP_LOGD(TAG, "Announcement finished playing"); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); @@ -507,8 +512,8 @@ void VoiceAssistant::loop() { } ESP_LOGD(TAG, "Speaker has finished outputting all audio"); this->speaker_->stop(); - this->cancel_timeout("speaker-timeout"); - this->cancel_timeout("playing"); + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); + this->cancel_timeout(PLAYBACK_TIMEOUT_ID); this->clear_buffers_(); @@ -540,7 +545,7 @@ void VoiceAssistant::write_speaker_() { memmove(this->speaker_buffer_, this->speaker_buffer_ + written, this->speaker_buffer_size_ - written); this->speaker_buffer_size_ -= written; this->speaker_buffer_index_ -= written; - this->set_timeout("speaker-timeout", 5000, [this]() { this->speaker_->stop(); }); + this->set_timeout(SPEAKER_IDLE_TIMEOUT_ID, 5000, [this]() { this->speaker_->stop(); }); } else { ESP_LOGV(TAG, "Speaker buffer full, trying again next loop"); } @@ -755,8 +760,8 @@ void VoiceAssistant::signal_stop_() { } void VoiceAssistant::start_playback_timeout_() { - this->set_timeout("playing", 2000, [this]() { - this->cancel_timeout("speaker-timeout"); + this->set_timeout(PLAYBACK_TIMEOUT_ID, 2000, [this]() { + this->cancel_timeout(SPEAKER_IDLE_TIMEOUT_ID); this->set_state_(State::RESPONSE_FINISHED, State::RESPONSE_FINISHED); if (this->api_client_ == nullptr) @@ -1046,10 +1051,10 @@ void VoiceAssistant::on_timer_event(const api::VoiceAssistantTimerEventResponse } if (this->timers_.empty()) { - this->cancel_interval("timer-event"); + this->cancel_interval(TIMER_TICK_INTERVAL_ID); this->timer_tick_running_ = false; } else if (!this->timer_tick_running_) { - this->set_interval("timer-event", 1000, [this]() { this->timer_tick_(); }); + this->set_interval(TIMER_TICK_INTERVAL_ID, 1000, [this]() { this->timer_tick_(); }); this->timer_tick_running_ = true; } } diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index 88ddb2066a..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,39 +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 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; } + // 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_; + 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/voltage_sampler/__init__.py b/esphome/components/voltage_sampler/__init__.py index e60918096e..1dad646322 100644 --- a/esphome/components/voltage_sampler/__init__.py +++ b/esphome/components/voltage_sampler/__init__.py @@ -1,4 +1,6 @@ import esphome.codegen as cg +DOMAIN = "voltage_sampler" + voltage_sampler_ns = cg.esphome_ns.namespace("voltage_sampler") VoltageSampler = voltage_sampler_ns.class_("VoltageSampler") diff --git a/esphome/components/wake_on_lan/__init__.py b/esphome/components/wake_on_lan/__init__.py index 90539e5d3c..ad052dd85e 100644 --- a/esphome/components/wake_on_lan/__init__.py +++ b/esphome/components/wake_on_lan/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@willwill2will54", "@clydebarrow"] +DOMAIN = "wake_on_lan" diff --git a/esphome/components/wake_on_lan/button.py b/esphome/components/wake_on_lan/button.py index 5e756d0057..f5d15f3cfa 100644 --- a/esphome/components/wake_on_lan/button.py +++ b/esphome/components/wake_on_lan/button.py @@ -1,33 +1,28 @@ import esphome.codegen as cg -from esphome.components import button +from esphome.components import button, socket import esphome.config_validation as cv from esphome.const import CONF_ID -from esphome.core import CORE from esphome.types import ConfigType +AUTO_LOAD = ["socket"] DEPENDENCIES = ["network"] -def AUTO_LOAD() -> list[str]: - if CORE.is_esp8266 or CORE.is_rp2: - return [] - return ["socket"] - - CONF_TARGET_MAC_ADDRESS = "target_mac_address" wake_on_lan_ns = cg.esphome_ns.namespace("wake_on_lan") WakeOnLanButton = wake_on_lan_ns.class_("WakeOnLanButton", button.Button, cg.Component) -CONFIG_SCHEMA = ( +CONFIG_SCHEMA = cv.All( button.button_schema(WakeOnLanButton) .extend(cv.COMPONENT_SCHEMA) .extend( { cv.Required(CONF_TARGET_MAC_ADDRESS): cv.mac_address, } - ) + ), + socket.consume_sockets(1, "wake_on_lan", socket.SocketType.UDP), ) diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index e46b96c86a..3815312c68 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -1,12 +1,11 @@ #include "wake_on_lan.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/core/log.h" -#include "esphome/components/network/ip_address.h" #include "esphome/components/network/util.h" namespace esphome::wake_on_lan { -static const char *const TAG = "wake_on_lan.button"; +ESPHOME_LOG_TAG(TAG, "wake_on_lan.button"); static const uint8_t PREFIX[6] = {255, 255, 255, 255, 255, 255}; void WakeOnLanButton::set_macaddr(uint8_t a, uint8_t b, uint8_t c, uint8_t d, uint8_t e, uint8_t f) { @@ -25,15 +24,22 @@ void WakeOnLanButton::dump_config() { } void WakeOnLanButton::press_action() { + if (this->broadcast_socket_ == nullptr) { + ESP_LOGW(TAG, "Socket not available"); + return; + } if (!network::is_connected()) { ESP_LOGW(TAG, "Network not connected"); return; } ESP_LOGI(TAG, "Sending Wake-on-LAN Packet"); -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) 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++) { @@ -43,29 +49,10 @@ void WakeOnLanButton::press_action() { addr_len) <= 0) { ESP_LOGW(TAG, "sendto() error %d", errno); } -#else - IPAddress broadcast = IPAddress(255, 255, 255, 255); - for (auto ip : esphome::network::get_ip_addresses()) { - if (ip.is_ip4()) { - if (this->udp_client_.beginPacketMulticast(broadcast, 9, ip, 128) != 0) { - this->udp_client_.write(PREFIX, 6); - for (size_t i = 0; i < 16; i++) { - this->udp_client_.write(macaddr_, 6); - } - if (this->udp_client_.endPacket() != 0) - return; - ESP_LOGW(TAG, "WOL broadcast failed"); - return; - } - } - } - ESP_LOGW(TAG, "No ip4 addresses to broadcast to"); -#endif } void WakeOnLanButton::setup() { -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); + this->broadcast_socket_ = socket::socket_udp_send(AF_INET, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); @@ -81,7 +68,6 @@ void WakeOnLanButton::setup() { if (err != 0) { this->status_set_warning(LOG_STR("Socket unable to set broadcast")); } -#endif } } // namespace esphome::wake_on_lan diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index cef60c54f8..6f89754f56 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -2,13 +2,9 @@ #include "esphome/core/defines.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" +#include "esphome/components/socket/socket.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) -#include "esphome/components/socket/socket.h" -#else -#include "WiFiUdp.h" -#endif namespace esphome::wake_on_lan { @@ -21,11 +17,7 @@ class WakeOnLanButton final : public button::Button, public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } protected: -#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) - std::unique_ptr broadcast_socket_{}; -#else - WiFiUDP udp_client_{}; -#endif + std::unique_ptr broadcast_socket_{}; void press_action() override; uint16_t port_{9}; uint8_t macaddr_[MAC_ADDRESS_SIZE]; diff --git a/esphome/components/watchdog/__init__.py b/esphome/components/watchdog/__init__.py index 6fb87e45a4..2608ff7c57 100644 --- a/esphome/components/watchdog/__init__.py +++ b/esphome/components/watchdog/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@oarcher"] +DOMAIN = "watchdog" diff --git a/esphome/components/watchdog/watchdog.cpp b/esphome/components/watchdog/watchdog.cpp index 2063faeb91..61c0baf686 100644 --- a/esphome/components/watchdog/watchdog.cpp +++ b/esphome/components/watchdog/watchdog.cpp @@ -16,7 +16,7 @@ namespace esphome::watchdog { -static const char *const TAG = "http_request.watchdog"; +ESPHOME_LOG_TAG(TAG, "http_request.watchdog"); WatchdogManager::WatchdogManager(uint32_t timeout_ms) : timeout_ms_(timeout_ms) { if (timeout_ms == 0) { diff --git a/esphome/components/water_heater/__init__.py b/esphome/components/water_heater/__init__.py index 6bb2b2f8fe..b5821e55af 100644 --- a/esphome/components/water_heater/__init__.py +++ b/esphome/components/water_heater/__init__.py @@ -18,6 +18,7 @@ from esphome.cpp_generator import MockObjClass from esphome.types import ConfigType CODEOWNERS = ["@dhoeben"] +DOMAIN = "water_heater" IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/water_heater/water_heater.cpp b/esphome/components/water_heater/water_heater.cpp index 1dc2d008a1..e5236a0491 100644 --- a/esphome/components/water_heater/water_heater.cpp +++ b/esphome/components/water_heater/water_heater.cpp @@ -11,7 +11,7 @@ namespace esphome::water_heater { -static const char *const TAG = "water_heater"; +ESPHOME_LOG_TAG(TAG, "water_heater"); void log_water_heater(const char *tag, const char *prefix, const char *type, WaterHeater *obj) { if (obj != nullptr) { @@ -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/__init__.py b/esphome/components/waveshare_epaper/__init__.py index b410406a58..4bcfcdfda5 100644 --- a/esphome/components/waveshare_epaper/__init__.py +++ b/esphome/components/waveshare_epaper/__init__.py @@ -1,4 +1,5 @@ CODEOWNERS = ["@clydebarrow"] +DOMAIN = "waveshare_epaper" DEPRECATED_COMPONENT = """ The 'waveshare_epaper' component is deprecated and no new models will be added to it. diff --git a/esphome/components/waveshare_epaper/waveshare_213v3.cpp b/esphome/components/waveshare_epaper/waveshare_213v3.cpp index cc9c34cb42..5e19782f6d 100644 --- a/esphome/components/waveshare_epaper/waveshare_213v3.cpp +++ b/esphome/components/waveshare_epaper/waveshare_213v3.cpp @@ -4,7 +4,7 @@ namespace esphome::waveshare_epaper { -static const char *const TAG = "waveshare_2.13v3"; +ESPHOME_LOG_TAG(TAG, "waveshare_2.13v3"); static const uint8_t PARTIAL_LUT[] = { 0x32, // cmd diff --git a/esphome/components/waveshare_epaper/waveshare_epaper.cpp b/esphome/components/waveshare_epaper/waveshare_epaper.cpp index 93f23424c0..60d711d1cd 100644 --- a/esphome/components/waveshare_epaper/waveshare_epaper.cpp +++ b/esphome/components/waveshare_epaper/waveshare_epaper.cpp @@ -7,7 +7,7 @@ namespace esphome::waveshare_epaper { -static const char *const TAG = "waveshare_epaper"; +ESPHOME_LOG_TAG(TAG, "waveshare_epaper"); static const uint8_t LUT_SIZE_WAVESHARE = 30; diff --git a/esphome/components/waveshare_io_ch32v003/__init__.py b/esphome/components/waveshare_io_ch32v003/__init__.py index 29a939c523..5e53d30149 100644 --- a/esphome/components/waveshare_io_ch32v003/__init__.py +++ b/esphome/components/waveshare_io_ch32v003/__init__.py @@ -14,6 +14,7 @@ from esphome.cpp_generator import MockObj from esphome.types import ConfigType CODEOWNERS = ["@latonita"] +DOMAIN = "waveshare_io_ch32v003" AUTO_LOAD = ["gpio_expander"] DEPENDENCIES = ["i2c"] diff --git a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp index 30458310ce..cef569b905 100644 --- a/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp +++ b/esphome/components/waveshare_io_ch32v003/output/waveshare_io_ch32v003_output.cpp @@ -4,7 +4,7 @@ namespace esphome::waveshare_io_ch32v003 { -static const char *const TAG = "waveshare_io_ch32v003.output"; +ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003.output"); void WaveshareIOCH32V003Output::write_state(float state) { uint8_t pwm_value = static_cast(state * 255.0f); diff --git a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp index 82da7451f9..71cb9ba79f 100644 --- a/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp +++ b/esphome/components/waveshare_io_ch32v003/sensor/waveshare_io_ch32v003_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::waveshare_io_ch32v003 { -static const char *const TAG = "waveshare_io_ch32v003.sensor"; +ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003.sensor"); float WaveshareIOCH32V003Sensor::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp index 8a58c7e7bb..159390086f 100644 --- a/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp +++ b/esphome/components/waveshare_io_ch32v003/waveshare_io_ch32v003.cpp @@ -10,7 +10,7 @@ static const uint8_t IO_EXTENSION_PWM_ADDR = 0x05; static const uint8_t IO_EXTENSION_ADC_ADDR = 0x06; static const uint8_t IO_EXTENSION_RTC_INT_ADDR = 0x07; -static const char *const TAG = "waveshare_io_ch32v003"; +ESPHOME_LOG_TAG(TAG, "waveshare_io_ch32v003"); void WaveshareIOCH32V003Component::setup() { this->mode_mask_ = 0xFF; // Set all pins to output mode diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2459163786..c48c72c06e 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -8,10 +8,12 @@ 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 from esphome.const import ( + CONF_AP, CONF_AUTH, CONF_COMPRESSION, CONF_CSS_INCLUDE, @@ -23,15 +25,19 @@ from esphome.const import ( CONF_JS_URL, CONF_LOCAL, CONF_LOG, + CONF_MANUAL_IP, CONF_NAME, + CONF_NETWORKS, CONF_OTA, CONF_PASSWORD, CONF_PORT, + CONF_STATIC_IP, CONF_TYPE, CONF_USERNAME, CONF_VERSION, CONF_WEB_SERVER, CONF_WEB_SERVER_ID, + CONF_WIFI, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -46,7 +52,22 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) -AUTO_LOAD = ["json", "web_server_base"] + +def AUTO_LOAD() -> list[str]: + # No config parameter: a dynamic auto-load would satisfy ota.web_server's dependency too late + auto_load = ["json", "web_server_base"] + # The AP mode DNS server needs socket; CORE.raw_config already has a wifi block from a package + wifi = CORE.raw_config.get(CONF_WIFI) if CORE.raw_config else None + if ( + CORE.is_esp32 + and wifi is not None + and (not isinstance(wifi, dict) or CONF_AP in wifi) + ): + auto_load.append("socket") + return auto_load + + +DOMAIN = "web_server" AUTH_TYPE_BASIC = "basic" AUTH_TYPE_DIGEST = "digest" @@ -62,7 +83,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 = {} @@ -209,9 +230,6 @@ def _final_validate_sorting(config: ConfigType) -> None: ) -FINAL_VALIDATE_SCHEMA = _final_validate_sorting - - def _consume_web_server_sockets(config: ConfigType) -> ConfigType: """Register socket needs for web_server component.""" from esphome.components import socket @@ -238,11 +256,11 @@ WEBSERVER_SORTING_SCHEMA = cv.Schema( { cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer), cv.Optional(CONF_SORTING_WEIGHT): cv.All( - cv.requires_component("web_server"), + cv.requires_component(DOMAIN), cv.float_, ), cv.Optional(CONF_SORTING_GROUP_ID): cv.All( - cv.requires_component("web_server"), + cv.requires_component(DOMAIN), cv.use_id(cg.int_), ), } @@ -338,6 +356,108 @@ async def add_entity_config(entity: MockObj, config: ConfigType) -> None: ) +def wifi_is_ap_only(wifi_config: ConfigType | None) -> bool: + """AP only: an access point and no network to join, so the device is only ever reached + through its own AP.""" + return ( + wifi_config is not None + and CONF_AP in wifi_config + and not wifi_config.get(CONF_NETWORKS) + ) + + +def serve_local(config: ConfigType, wifi_config: ConfigType | None) -> bool: + """Embed the interface unless ``local:`` says otherwise; AP only WiFi has no internet + for the hosted page. Version 1 has no local mode.""" + if (local := config.get(CONF_LOCAL)) is not None: + return local + return config[CONF_VERSION] != 1 and wifi_is_ap_only(wifi_config) + + +def _captive_portal_configured(full_config: ConfigType) -> bool: + # Imported here: captive_portal pulls in wifi, which sensor (through web_server) must not + from esphome.components.captive_portal import DOMAIN as CAPTIVE_PORTAL_DOMAIN + + return CAPTIVE_PORTAL_DOMAIN in full_config + + +def serve_captive(config: ConfigType, full_config: ConfigType) -> bool: + """Serve the embedded interface as a captive portal while the AP is up, unless captive_portal + owns that role. Port 80 only: the OS probes and the DHCP portal URI never use another port.""" + wifi_config = full_config.get(CONF_WIFI) + return ( + not _captive_portal_configured(full_config) + and config[CONF_PORT] == 80 + and wifi_config is not None + and CONF_AP in wifi_config + and serve_local(config, wifi_config) + ) + + +def _final_validate_ap_mode(config: ConfigType) -> None: + full_config = fv.full_config.get() + wifi_config = full_config.get(CONF_WIFI) + captive = serve_captive(config, full_config) + local = serve_local(config, wifi_config) + ap_only = wifi_is_ap_only(wifi_config) + if captive: + web_server_base.consume_captive_dns_sockets(config, DOMAIN) + # Surface behavior that the config does not spell out. + if local and CONF_LOCAL not in config: + _LOGGER.info( + "WiFi is AP only: embedding the web interface in the firmware " + "(local: true, roughly 13 KB of flash for version 2, 78 KB for version 3)%s. " + "Set 'local: false' to load it from the internet instead.", + " and serving it as a captive portal on the access point" + if captive + else "", + ) + elif captive: + _LOGGER.info( + "web_server will act as a captive portal while the %saccess point is active.", + "" if ap_only else "fallback ", + ) + # An embedded interface on an AP (AP only, or a fallback with local: true) that is not + # on port 80 cannot be a captive portal; say so, unless captive_portal has that role. + if ( + local + and config[CONF_PORT] != 80 + and wifi_config is not None + and CONF_AP in wifi_config + and not _captive_portal_configured(full_config) + ): + ap_ip = "192.168.4.1" + if (manual_ip := wifi_config[CONF_AP].get(CONF_MANUAL_IP)) is not None: + ap_ip = str(manual_ip[CONF_STATIC_IP]) + _LOGGER.warning( + "web_server uses port %d, so the interface cannot open automatically on the " + "%saccess point (captive portal detection only works on port 80); open " + "http://%s:%d/ manually, or remove 'port:' to use 80.", + config[CONF_PORT], + "" if ap_only else "fallback ", + ap_ip, + config[CONF_PORT], + ) + if ap_only and not local: + _LOGGER.warning( + "WiFi is AP only and the web_server interface is loaded from the internet, " + "which browsers on the access point usually cannot reach; the page stays " + "blank. %s so the interface is embedded in the firmware.", + "Remove 'local: false'" + if config.get(CONF_LOCAL) is False + else "Migrate to version 2 or 3", + ) + + +def _final_validate(config: ConfigType) -> None: + # Called one after the other rather than via cv.All: these return None. + _final_validate_sorting(config) + _final_validate_ap_mode(config) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def build_index_html(config: ConfigType) -> str: html = "" css_include = config.get(CONF_CSS_INCLUDE) @@ -363,12 +483,14 @@ def add_resource_as_progmem( content_encoded = content.encode("utf-8") if compress: content_encoded = gzip.compress(content_encoded) - content_encoded_size = len(content_encoded) - bytes_as_int = ", ".join(str(x) for x in content_encoded) - uint8_t = f"constexpr uint8_t ESPHOME_WEBSERVER_{resource_name}[{content_encoded_size}] PROGMEM = {{{bytes_as_int}}}" - size_t = f"constexpr size_t ESPHOME_WEBSERVER_{resource_name}_SIZE = {content_encoded_size}" - cg.add_global(cg.RawExpression(uint8_t)) - cg.add_global(cg.RawExpression(size_t)) + cg.extern_progmem_array( + f"ESPHOME_WEBSERVER_{resource_name}", cg.uint8, list(content_encoded) + ) + cg.add_global( + cg.RawExpression( + f"constexpr size_t ESPHOME_WEBSERVER_{resource_name}_SIZE = {len(content_encoded)}" + ) + ) @coroutine_with_priority(CoroPriority.WEB) @@ -378,8 +500,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 +509,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 +534,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 @@ -444,8 +577,12 @@ async def to_code(config: ConfigType) -> None: # include_internal_ is false in C++; only emit the setter to turn it on. if config[CONF_INCLUDE_INTERNAL]: cg.add(var.set_include_internal(True)) - if CONF_LOCAL in config and config[CONF_LOCAL]: + if serve_local(config, CORE.config.get(CONF_WIFI)): cg.add_define("USE_WEBSERVER_LOCAL") + if serve_captive(config, CORE.config): + # AP mode: DNS server plus redirect of unknown URLs so phones open the interface + cg.add_define("USE_WEBSERVER_CAPTIVE") + web_server_base.add_captive_dns_library() if config[CONF_COMPRESSION] == "gzip": cg.add_define("USE_WEBSERVER_GZIP") @@ -459,7 +596,7 @@ def FILTER_SOURCE_FILES() -> list[str]: files_to_filter: list[str] = [] # web_server_v1.cpp is only needed when version is 1 - config = CORE.config.get("web_server", {}) + config = CORE.config.get(DOMAIN, {}) if config.get(CONF_VERSION, 2) != 1: files_to_filter.append("web_server_v1.cpp") diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 03a5c2ca9b..937d99ede8 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -2,15 +2,22 @@ import logging import esphome.codegen as cg from esphome.components.esp32 import add_idf_component -from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +from esphome.components.ota import ( + BASE_OTA_SCHEMA, + DOMAIN as OTA_DOMAIN, + OTAComponent, + ota_to_code, +) from esphome.config_helpers import merge_config import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER +from esphome.const import CONF_ID, CONF_PLATFORM from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority import esphome.final_validate as fv from esphome.types import ConfigType +from .. import DOMAIN + _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] @@ -27,13 +34,13 @@ def _web_server_ota_final_validate(config: ConfigType) -> None: causing undefined behavior. Merge them into a single instance. """ full_conf = fv.full_config.get() - ota_confs = full_conf.get(CONF_OTA, []) + ota_confs = full_conf.get(OTA_DOMAIN, []) web_server_ota_configs: list[ConfigType] = [] other_ota_configs: list[ConfigType] = [] for ota_conf in ota_confs: - if ota_conf.get(CONF_PLATFORM) == CONF_WEB_SERVER: + if ota_conf.get(CONF_PLATFORM) == DOMAIN: web_server_ota_configs.append(ota_conf) else: other_ota_configs.append(ota_conf) @@ -62,7 +69,7 @@ def _web_server_ota_final_validate(config: ConfigType) -> None: # Replace OTA configs with merged web_server + other OTA platforms other_ota_configs.append(merged) - full_conf[CONF_OTA] = other_ota_configs + full_conf[OTA_DOMAIN] = other_ota_configs fv.full_config.set(full_conf) diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 95763e2daf..8a668ae0fc 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -9,12 +9,6 @@ #include "esphome/components/captive_portal/captive_portal.h" #endif -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) -#include -#endif -#endif // USE_ARDUINO - #if USE_ESP32 using PlatformString = std::string; #elif USE_ARDUINO @@ -23,7 +17,7 @@ using PlatformString = String; namespace esphome::web_server { -static const char *const TAG = "web_server.ota"; +ESPHOME_LOG_TAG(TAG, "web_server.ota"); class OTARequestHandler : public AsyncWebHandler { public: @@ -64,6 +58,10 @@ class OTARequestHandler : public AsyncWebHandler { void report_ota_progress_(AsyncWebServerRequest *request); void schedule_ota_reboot_(); void ota_init_(const char *filename); + /// Abort and drop the backend, releasing the lock. + void abort_session_(); + /// Reply "busy" without touching the running upload. + void refuse_(AsyncWebServerRequest *request); uint32_t last_ota_progress_{0}; uint32_t ota_read_length_{0}; @@ -72,6 +70,9 @@ class OTARequestHandler : public AsyncWebHandler { private: ota::OTABackendPtr ota_backend_{nullptr}; + /// Request that owns ota_backend_. + const AsyncWebServerRequest *ota_request_{nullptr}; + const AsyncWebServerRequest *refused_request_{nullptr}; }; void OTARequestHandler::report_ota_progress_(AsyncWebServerRequest *request) { @@ -118,38 +119,13 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // fires a separate start-marker call with data==nullptr/len==0 before the // first real chunk; gate on len>0 so we only trigger once per upload.) if (index == 0 && len > 0) { - // If a previous upload was interrupted (e.g. client closed the tab, TCP - // reset) the backend from that session may still be open. Tear it down - // so flash state doesn't get concatenated with the new image (which can - // produce a technically-valid-sized but corrupted firmware that bricks - // the device once it reboots). + // A stalled upload times out and onDisconnect() frees it, so refusing can't block for long. if (this->ota_backend_) { - ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session"); - this->ota_backend_->abort(); -#ifdef USE_OTA_STATE_LISTENER - // Notify listeners that the previous session was aborted before the new one starts. - this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); -#endif - this->ota_backend_.reset(); + ESP_LOGW(TAG, "OTA upload refused: another upload is in progress"); + this->refuse_(request); + return; } - // Initialize OTA on first call - this->ota_init_(filename.c_str()); - -#ifdef USE_OTA_STATE_LISTENER - // Notify OTA started - use notify_state_deferred_ since we're in web server task - this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); -#endif - - // Platform-specific pre-initialization -#ifdef USE_ARDUINO -#if defined(USE_LIBRETINY) - if (Update.isRunning()) { - Update.abort(); - } -#endif -#endif // USE_ARDUINO - this->ota_backend_ = ota::make_ota_backend(); if (!this->ota_backend_) { ESP_LOGE(TAG, "Failed to create OTA backend"); @@ -164,6 +140,22 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf // is unknown (contentLength includes multipart overhead) // Pass 0 to indicate unknown size error_code = this->ota_backend_->begin(0); + if (error_code == ota::OTA_RESPONSE_ERROR_UPDATE_BUSY) { + // Nothing started: no state notifications. + ESP_LOGW(TAG, "OTA upload refused: another update is in progress"); + this->ota_backend_.reset(); + this->refuse_(request); + return; + } + + // Initialize OTA on first call + this->ota_init_(filename.c_str()); + +#ifdef USE_OTA_STATE_LISTENER + // Notify OTA started - use notify_state_deferred_ since we're in web server task + this->parent_->notify_state_deferred_(ota::OTA_STARTED, 0.0f, 0); +#endif + if (error_code != ota::OTA_RESPONSE_OK) { ESP_LOGE(TAG, "OTA begin failed: %d", error_code); this->ota_backend_.reset(); @@ -172,9 +164,18 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf #endif return; } + + // An interrupted upload gets no final chunk. + this->ota_request_ = request; + request->onDisconnect([this, request]() { + if (this->ota_request_ == request && this->ota_backend_) { + ESP_LOGW(TAG, "OTA upload interrupted; aborting"); + this->abort_session_(); + } + }); } - if (!this->ota_backend_) { + if (!this->ota_backend_ || request != this->ota_request_) { return; } @@ -185,6 +186,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf ESP_LOGE(TAG, "OTA write failed: %d", error_code); this->ota_backend_->abort(); this->ota_backend_.reset(); + this->ota_request_ = nullptr; #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif @@ -212,28 +214,54 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf this->schedule_ota_reboot_(); } else { ESP_LOGE(TAG, "OTA end failed: %d", error_code); + // Reset the platform updater before the lock is released. + this->ota_backend_->abort(); #ifdef USE_OTA_STATE_LISTENER this->parent_->notify_state_deferred_(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif } this->ota_backend_.reset(); + this->ota_request_ = nullptr; } } +void OTARequestHandler::abort_session_() { + this->ota_backend_->abort(); +#ifdef USE_OTA_STATE_LISTENER + this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0); +#endif + this->ota_backend_.reset(); + this->ota_request_ = nullptr; +} + +void OTARequestHandler::refuse_(AsyncWebServerRequest *request) { + this->refused_request_ = request; + request->onDisconnect([this, request]() { + if (this->refused_request_ == request) + this->refused_request_ = nullptr; + }); +} + void OTARequestHandler::handleRequest(AsyncWebServerRequest *request) { AsyncWebServerResponse *response; // Use the ota_success_ flag to determine the actual result + const bool refused = request == this->refused_request_; + if (refused) + this->refused_request_ = nullptr; #ifdef USE_ESP8266 static const char UPDATE_SUCCESS[] PROGMEM = "Update Successful!"; static const char UPDATE_FAILED[] PROGMEM = "Update Failed!"; + static const char UPDATE_BUSY[] PROGMEM = "Update Failed! Another update is in progress, retry later."; static const char TEXT_PLAIN[] PROGMEM = "text/plain"; static const char CONNECTION_STR[] PROGMEM = "Connection"; static const char CLOSE_STR[] PROGMEM = "close"; - const char *msg = this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED; + const char *msg = refused ? UPDATE_BUSY : (this->ota_success_ ? UPDATE_SUCCESS : UPDATE_FAILED); response = request->beginResponse_P(200, TEXT_PLAIN, msg); response->addHeader(CONNECTION_STR, CLOSE_STR); #else - const char *msg = this->ota_success_ ? "Update Successful!" : "Update Failed!"; + const char *msg = refused ? "Update Failed! Another update is in progress, retry later." + : this->ota_success_ ? "Update Successful!" + : "Update Failed!"; response = request->beginResponse(200, "text/plain", msg); response->addHeader("Connection", "close"); #endif diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index bad3d050f3..b0da010561 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" @@ -44,6 +45,10 @@ #include "esphome/components/radio_frequency/radio_frequency.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/wifi/wifi_component.h" +#endif + #ifdef USE_WEBSERVER_LOCAL #if USE_WEBSERVER_VERSION == 2 #include "server_index_v2.h" @@ -54,10 +59,54 @@ namespace esphome::web_server { -static const char *const TAG = "web_server"; +ESPHOME_LOG_TAG(TAG, "web_server"); +using json::linked; + +#ifdef USE_STORE_LOG_STR_IN_FLASH // 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); } +#else +// Without the define a LogString is in RAM and can be linked +[[maybe_unused]] static JsonString json_state_str(const LogString *s) { return linked(LOG_STR_ARG(s)); } +#endif + +// get_icon_to() and get_device_class_to() return a table pointer, except on ESP8266 where they +// copy out of PROGMEM into the caller's buffer +[[maybe_unused]] static JsonString json_table_str(const char *s) { +#ifdef USE_ESP8266 + return JsonString(s); +#else + return linked(s); +#endif +} + +// One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the +// table needs no RAM on ESP8266. +template struct ActionEntry { + char name[16]; + Call &(Call::*action)(); +}; + +// Runs the entry whose name matches the request's action; false when none does. +template +static bool run_action(const UrlMatch &match, Call &call, const ActionEntry (&table)[N]) { + for (const auto &entry : table) { + if (match.method_equals(reinterpret_cast(entry.name))) { + Call &(Call::*action)(); + progmem_memcpy(&action, &entry.action, sizeof(action)); + (call.*action)(); + return true; + } + } + return false; +} + +// 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): @@ -194,13 +243,15 @@ 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_(). + // 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. @@ -251,7 +302,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 { @@ -316,7 +369,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("name")] = group.second.name; + root[ESPHOME_F("name")] = linked(group.second.name); root[ESPHOME_F("sorting_weight")] = group.second.weight; auto group_msg = builder.serialize(); @@ -339,7 +392,15 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou } #endif -WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +#ifdef USE_WEBSERVER_CAPTIVE +WebServer *global_web_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + +WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) { +#ifdef USE_WEBSERVER_CAPTIVE + global_web_server = this; +#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); } @@ -348,7 +409,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); + root[ESPHOME_F("title")] = linked(App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name()); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); root[ESPHOME_F("comment")] = static_cast(comment_buffer); @@ -365,7 +426,6 @@ json::SerializationBuffer<> WebServer::get_config_json() { } void WebServer::setup() { - ControllerRegistry::register_controller(this); this->base_->init(); #ifdef USE_LOGGER @@ -381,6 +441,11 @@ void WebServer::setup() { this->base_->add_handler(&this->events_); #endif this->base_->add_handler(this); +#ifdef USE_WEBSERVER_CAPTIVE + // Not-found fallback (outside the auth middleware): the OS captive portal probes hit + // arbitrary URLs and must get the redirect without credentials. + this->base_->get_server()->onNotFound([this](AsyncWebServerRequest *request) { this->handle_not_found_(request); }); +#endif // OTA is now handled by the web_server OTA platform @@ -396,21 +461,60 @@ void WebServer::setup() { }); } void WebServer::loop() { - // No SSE clients connected; stop looping until a new client connects via + bool keep_looping = this->events_.loop(); +#ifdef USE_WEBSERVER_CAPTIVE + this->dns_.loop(); + keep_looping |= this->dns_.is_running(); +#endif + // No SSE clients connected (and no captive DNS to serve); stop looping until a new client connects via // enable_loop_soon_any_context(). This is safe because: // - set_interval/set_timeout/defer run via the Scheduler, independent of loop() // - deferrable_send_state early-outs when no clients are connected // - try_send_nodefer (log, ping) iterates sessions which are empty // - REST API handlers use defer() which runs via the Scheduler - if (!this->events_.loop()) + if (!keep_looping) this->disable_loop(); } +#ifdef USE_WEBSERVER_CAPTIVE +void WebServer::start_captive() { + // Avoid repeating the log and enable_loop on a second start + if (this->dns_.is_running()) + return; + network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + if (!this->dns_.start(ip)) { + ESP_LOGW(TAG, "AP mode: captive DNS failed to start; open http://%s/ manually", ip.str_to(ip_buf)); + return; + } + this->enable_loop(); + ESP_LOGI(TAG, "AP mode: serving the web interface as captive portal at http://%s/", ip.str_to(ip_buf)); +} + +void WebServer::end_captive() { this->dns_.stop(); } + +void WebServer::handle_not_found_(AsyncWebServerRequest *request) { + // OS captive portal probe while the AP is up: a redirect rather than the page itself, because + // the interface resolves its /events and REST paths relative to the page URL. + if (this->dns_.is_running() && request->method() == HTTP_GET) { + char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; + wifi::global_wifi_component->wifi_soft_ap_ip().str_to(ip_buf); + // Captive mode requires port 80 (enforced at validation), so no port suffix + char location[7 + network::IP_ADDRESS_BUFFER_SIZE + 1]; + buf_append_printf(location, sizeof(location), 0, "http://%s/", ip_buf); + request->redirect(location); + return; + } + request->send(404); +} +#endif + #ifdef USE_LOGGER + void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { (void) level; (void) tag; - this->events_.try_send_nodefer(message, message_len, "log", millis()); + this->events_.try_send_nodefer(message, std::min(message_len, LOG_EVENT_MAX_LEN), "log", millis()); } #endif @@ -484,7 +588,8 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const #ifdef USE_WEBSERVER_ALLOWED_ORIGINS // Otherwise the origin must be explicitly allowed via configuration. - for (const char *allowed_origin : this->allowed_origins_) { + for (const char *const *it = this->allowed_origins_; *it != nullptr; it++) { + const char *allowed_origin = *it; // A single "*" entry allows any origin. if (allowed_origin[0] == '*' && allowed_origin[1] == '\0') return true; @@ -583,17 +688,16 @@ static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, Js root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { - root[ESPHOME_F("domain")] = prefix; - // Use .c_str() to avoid instantiating set template (saves ~24B) - root[ESPHOME_F("name")] = name.c_str(); + root[ESPHOME_F("domain")] = linked(prefix, prefix_len); + root[ESPHOME_F("name")] = linked(name); #ifdef USE_DEVICES if (device_name) { - root[ESPHOME_F("device")] = device_name; + root[ESPHOME_F("device")] = linked(device_name, device_len); } #endif #ifdef USE_ENTITY_ICON char icon_buf[MAX_ICON_LENGTH]; - root[ESPHOME_F("icon")] = obj->get_icon_to(icon_buf); + root[ESPHOME_F("icon")] = json_table_str(obj->get_icon_to(icon_buf)); #endif root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); @@ -636,21 +740,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(); @@ -662,10 +766,8 @@ json::SerializationBuffer<> WebServer::sensor_json_(sensor::Sensor *obj, float v if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); } - - return builder.serialize(); } #endif @@ -683,32 +785,32 @@ 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); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString state(value.c_str(), value.size()); + set_json_icon_state_value(root, obj, "text_sensor", state, state, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -744,8 +846,9 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->switch_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->switch_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } @@ -769,14 +872,13 @@ void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::switch_state_json_generator(WebServer *web_server, void *source) { - return web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_STATE); +void WebServer::switch_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::switch_all_json_generator(WebServer *web_server, void *source) { - return web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_ALL); +void WebServer::switch_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->switch_json_((switch_::Switch *) (source), ((switch_::Switch *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "switch", value ? "ON" : "OFF", value, start_config); @@ -784,8 +886,6 @@ json::SerializationBuffer<> WebServer::switch_json_(switch_::Switch *obj, bool v root[ESPHOME_F("assumed_state")] = obj->assumed_state(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -797,8 +897,9 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->button_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->button_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("press"))) { DEFER_ACTION(obj, obj->press()); request->send(200); @@ -810,19 +911,16 @@ void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlM } request->send(404); } -json::SerializationBuffer<> WebServer::button_all_json_generator(WebServer *web_server, void *source) { - return web_server->button_json_((button::Button *) (source), DETAIL_ALL); +void WebServer::button_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->button_json_((button::Button *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::button_json_(button::Button *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::button_json_(button::Button *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_id(root, obj, "button", start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -840,32 +938,30 @@ void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, con // Note: request->method() is always HTTP_GET here (canHandle ensures this) if (entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->binary_sensor_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->binary_sensor_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } } request->send(404); } -json::SerializationBuffer<> WebServer::binary_sensor_state_json_generator(WebServer *web_server, void *source) { - return web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), - ((binary_sensor::BinarySensor *) (source))->state, DETAIL_STATE); +void WebServer::binary_sensor_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), + ((binary_sensor::BinarySensor *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::binary_sensor_all_json_generator(WebServer *web_server, void *source) { - return web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), - ((binary_sensor::BinarySensor *) (source))->state, DETAIL_ALL); +void WebServer::binary_sensor_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->binary_sensor_json_((binary_sensor::BinarySensor *) (source), + ((binary_sensor::BinarySensor *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, - JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::binary_sensor_json_(binary_sensor::BinarySensor *obj, bool value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "binary_sensor", value ? "ON" : "OFF", value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -883,8 +979,9 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->fan_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->fan_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("toggle"))) { DEFER_ACTION(obj, obj->toggle().perform()); request->send(200); @@ -924,14 +1021,13 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } request->send(404); } -json::SerializationBuffer<> WebServer::fan_state_json_generator(WebServer *web_server, void *source) { - return web_server->fan_json_((fan::Fan *) (source), DETAIL_STATE); +void WebServer::fan_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->fan_json_((fan::Fan *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::fan_all_json_generator(WebServer *web_server, void *source) { - return web_server->fan_json_((fan::Fan *) (source), DETAIL_ALL); +void WebServer::fan_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->fan_json_((fan::Fan *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::fan_json_(fan::Fan *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "fan", obj->state ? "ON" : "OFF", obj->state, start_config); @@ -945,8 +1041,6 @@ json::SerializationBuffer<> WebServer::fan_json_(fan::Fan *obj, JsonDetail start if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -964,8 +1058,9 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->light_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->light_json_(obj, detail, builder); + send_json(request, builder); } else if (match.method_equals(ESPHOME_F("toggle"))) { DEFER_ACTION(obj, obj->toggle().perform()); request->send(200); @@ -1005,29 +1100,26 @@ 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) { JsonArray opt = root[ESPHOME_F("effects")].to(); - opt.add("None"); + opt.add(linked("None")); for (auto const &option : obj->get_effects()) { - opt.add(option->get_name()); + opt.add(linked(option->get_name())); } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1045,34 +1137,22 @@ 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; } auto call = obj->make_call(); - // Lookup table for cover methods - static const struct { - const char *name; - cover::CoverCall &(cover::CoverCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &cover::CoverCall::set_command_open}, {"close", &cover::CoverCall::set_command_close}, {"stop", &cover::CoverCall::set_command_stop}, {"toggle", &cover::CoverCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1093,17 +1173,16 @@ 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, + set_json_icon_state_value(root, obj, "cover", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); @@ -1115,8 +1194,6 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail root[ESPHOME_F("assumed_state")] = obj->get_traits().get_is_assumed_state(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1134,8 +1211,9 @@ void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlM if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->number_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->number_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1153,14 +1231,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(); @@ -1169,9 +1246,14 @@ json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float v // Need two buffers: one for value, one for state with UOM char val_buf[VALUE_ACCURACY_MAX_LEN]; char state_buf[VALUE_ACCURACY_MAX_LEN]; - const char *val_str = std::isnan(value) ? "\"NaN\"" : (value_accuracy_to_buf(val_buf, value, accuracy), val_buf); - const char *state_str = - std::isnan(value) ? "NA" : (value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref), state_buf); + JsonString val_str = linked("\"NaN\""); + JsonString state_str = linked("NA"); + if (!std::isnan(value)) { + value_accuracy_to_buf(val_buf, value, accuracy); + value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref); + val_str = JsonString(val_buf); + state_str = JsonString(state_buf); + } set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { // ArduinoJson copies the string immediately, so we can reuse val_buf @@ -1180,11 +1262,9 @@ json::SerializationBuffer<> WebServer::number_json_(number::Number *obj, float v root[ESPHOME_F("step")] = (value_accuracy_to_buf(val_buf, obj->traits.get_step(), accuracy), val_buf); root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1201,8 +1281,9 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->date_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->date_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1227,14 +1308,13 @@ void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMat request->send(404); } -json::SerializationBuffer<> WebServer::date_state_json_generator(WebServer *web_server, void *source) { - return web_server->date_json_((datetime::DateEntity *) (source), DETAIL_STATE); +void WebServer::date_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->date_json_((datetime::DateEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::date_all_json_generator(WebServer *web_server, void *source) { - return web_server->date_json_((datetime::DateEntity *) (source), DETAIL_ALL); +void WebServer::date_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->date_json_((datetime::DateEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::date_json_(datetime::DateEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: YYYY-MM-DD (max 10 chars + null) @@ -1244,8 +1324,6 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATE @@ -1262,8 +1340,9 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->time_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->time_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1287,14 +1366,13 @@ void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMat } request->send(404); } -json::SerializationBuffer<> WebServer::time_state_json_generator(WebServer *web_server, void *source) { - return web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_STATE); +void WebServer::time_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::time_all_json_generator(WebServer *web_server, void *source) { - return web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_ALL); +void WebServer::time_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->time_json_((datetime::TimeEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::time_json_(datetime::TimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: HH:MM:SS (8 chars + null) @@ -1304,8 +1382,6 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_TIME @@ -1322,8 +1398,9 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur continue; if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->datetime_json_(obj, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->datetime_json_(obj, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1347,14 +1424,13 @@ void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const Ur } request->send(404); } -json::SerializationBuffer<> WebServer::datetime_state_json_generator(WebServer *web_server, void *source) { - return web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_STATE); +void WebServer::datetime_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::datetime_all_json_generator(WebServer *web_server, void *source) { - return web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_ALL); +void WebServer::datetime_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->datetime_json_((datetime::DateTimeEntity *) (source), DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::datetime_json_(datetime::DateTimeEntity *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); // Format: YYYY-MM-DD HH:MM:SS (max 19 chars + null) @@ -1365,8 +1441,6 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity * if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif // USE_DATETIME_DATETIME @@ -1384,8 +1458,9 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat if (request->method() == HTTP_GET && entity_match.action_is_empty) { auto detail = get_request_detail(request); - auto data = this->text_json_(obj, obj->state, detail); - request->send(200, "application/json", data.c_str()); + json::JsonBuilder builder; + this->text_json_(obj, obj->state, detail, builder); + send_json(request, builder); return; } if (!match.method_equals(ESPHOME_F("set"))) { @@ -1405,27 +1480,30 @@ void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMat request->send(404); } -json::SerializationBuffer<> WebServer::text_state_json_generator(WebServer *web_server, void *source) { - return web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_STATE); +void WebServer::text_state_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_STATE, builder); } -json::SerializationBuffer<> WebServer::text_all_json_generator(WebServer *web_server, void *source) { - return web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_ALL); +void WebServer::text_all_json_generator(WebServer *web_server, void *source, json::JsonBuilder &builder) { + web_server->text_json_((text::Text *) (source), ((text::Text *) (source))->state, DETAIL_ALL, builder); } -json::SerializationBuffer<> WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail start_config) { - json::JsonBuilder builder; +void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail start_config, + json::JsonBuilder &builder) { JsonObject root = builder.root(); - const char *state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value.c_str(); - set_json_icon_state_value(root, obj, "text", state, value.c_str(), start_config); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString copied_value(value.c_str(), value.size()); + // A password entity shows the mask and prefills the input with nothing, so the secret never + // reaches the JSON and the mask cannot be written back as the value + const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; + set_json_icon_state_value(root, obj, "text", password ? linked("********") : copied_value, + password ? linked("") : copied_value, start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); - root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); + root[ESPHOME_F("pattern")] = linked(obj->traits.get_pattern_c_str()); if (start_config == DETAIL_ALL) { root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1443,8 +1521,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,29 +1543,27 @@ 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()) - set_json_icon_state_value(root, obj, "select", value.c_str(), value.c_str(), start_config); + const JsonString state = linked(value); + set_json_icon_state_value(root, obj, "select", state, state, start_config); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("option")].to(); for (auto &option : obj->traits.get_options()) { - opt.add(option); + opt.add(linked(option)); } this->add_sorting_info_(root, obj); } - - return builder.serialize(); } #endif @@ -1504,8 +1581,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 +1624,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(); @@ -1577,7 +1654,7 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json if (!traits.get_supported_custom_fan_modes().empty()) { JsonArray opt = root[ESPHOME_F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - opt.add(custom_fan_mode); + opt.add(linked(custom_fan_mode)); } if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); @@ -1592,13 +1669,14 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) - opt.add(custom_preset); + opt.add(linked(custom_preset)); } root[ESPHOME_F("max_temp")] = (value_accuracy_to_buf(temp_buf, traits.get_visual_max_temperature(), target_accuracy), temp_buf); 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); } @@ -1613,13 +1691,13 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); + root[ESPHOME_F("custom_fan_mode")] = linked(obj->get_custom_fan_mode()); } if (traits.get_supports_presets() && obj->preset.has_value()) { root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); + root[ESPHOME_F("custom_preset")] = linked(obj->get_custom_preset()); } if (traits.get_supports_swing_modes()) { root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); @@ -1654,7 +1732,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 +1768,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 +1794,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,34 +1825,22 @@ 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; } auto call = obj->make_call(); - // Lookup table for valve methods - static const struct { - const char *name; - valve::ValveCall &(valve::ValveCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"open", &valve::ValveCall::set_command_open}, {"close", &valve::ValveCall::set_command_close}, {"stop", &valve::ValveCall::set_command_stop}, {"toggle", &valve::ValveCall::set_command_toggle}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found && !match.method_equals(ESPHOME_F("set"))) { + if (!run_action(match, call, ACTIONS) && !match.method_equals(ESPHOME_F("set"))) { request->send(404); return; } @@ -1795,17 +1859,16 @@ 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, + set_json_icon_state_value(root, obj, "valve", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); @@ -1814,8 +1877,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 +1894,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; } @@ -1844,11 +1906,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques static_cast(&decltype(call)::set_code)); - // Lookup table for alarm control panel methods - static const struct { - const char *name; - alarm_control_panel::AlarmControlPanelCall &(alarm_control_panel::AlarmControlPanelCall::*action)(); - } METHODS[] = { + static constexpr ActionEntry ACTIONS[] PROGMEM = { {"disarm", &alarm_control_panel::AlarmControlPanelCall::disarm}, {"arm_away", &alarm_control_panel::AlarmControlPanelCall::arm_away}, {"arm_home", &alarm_control_panel::AlarmControlPanelCall::arm_home}, @@ -1856,16 +1914,7 @@ void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *reques {"arm_vacation", &alarm_control_panel::AlarmControlPanelCall::arm_vacation}, }; - bool found = false; - for (const auto &method : METHODS) { - if (match.method_equals(method.name)) { - (call.*method.action)(); - found = true; - break; - } - } - - if (!found) { + if (!run_action(match, call, ACTIONS)) { request->send(404); return; } @@ -1876,20 +1925,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 +1947,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 +1964,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,21 +2004,20 @@ 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(); - ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); - - set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); + set_json_icon_state_value(root, obj, "water_heater", json_state_str(water_heater::water_heater_mode_to_string(mode)), + mode, start_config); auto traits = obj->get_traits(); @@ -1980,6 +2028,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 +2058,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 +2070,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 +2131,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 +2159,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 +2219,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 +2243,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 +2262,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,34 +2273,32 @@ 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); if (!event_type.empty()) { - root[ESPHOME_F("event_type")] = event_type; + root[ESPHOME_F("event_type")] = linked(event_type); } if (start_config == DETAIL_ALL) { JsonArray event_types = root[ESPHOME_F("event_types")].to(); for (const char *event_type : obj->get_event_types()) { - event_types.add(event_type); + event_types.add(linked(event_type)); } char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - root[ESPHOME_F("device_class")] = obj->get_device_class_to(dc_buf); + root[ESPHOME_F("device_class")] = json_table_str(obj->get_device_class_to(dc_buf)); this->add_sorting_info_(root, obj); } - - return builder.serialize(); } // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) #endif @@ -2275,8 +2315,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,34 +2332,32 @@ 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(); + // Not linked: the main loop can rewrite update_info before the document is serialized + const auto &info = obj->update_info; set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version.c_str(), start_config); + 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; - // Truncate long changelogs — full text available via release_url + root[ESPHOME_F("current_version")] = info.current_version; + root[ESPHOME_F("title")] = info.title; + // Truncate long changelogs, the full text is available via release_url constexpr size_t max_summary_len = 256; - root[ESPHOME_F("summary")] = obj->update_info.summary.size() <= max_summary_len - ? obj->update_info.summary - : obj->update_info.summary.substr(0, max_summary_len); - root[ESPHOME_F("release_url")] = obj->update_info.release_url; + root[ESPHOME_F("summary")] = JsonString(info.summary.c_str(), std::min(info.summary.size(), max_summary_len)); + root[ESPHOME_F("release_url")] = info.release_url; this->add_sorting_info_(root, obj); } - return builder.serialize(); // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } #endif @@ -2650,7 +2689,7 @@ void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { if (this->sorting_entitys_.contains(entity)) { root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) { - root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[ESPHOME_F("sorting_group")] = linked(this->sorting_groups_[this->sorting_entitys_[entity].group_id].name); } } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 3e50eeeec0..d3bd1ae0da 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -4,10 +4,11 @@ #include "esphome/components/json/json_util.h" #include "esphome/components/web_server_base/web_server_base.h" +#include "esphome/components/web_server_base/captive_dns.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" @@ -37,6 +38,10 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE; namespace esphome::web_server { +// 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; @@ -101,7 +106,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 { @@ -187,7 +192,7 @@ class DeferredUpdateEventSourceList final : public std::list origins) { this->allowed_origins_ = origins; } + void set_allowed_origins(const char *const *origins) { this->allowed_origins_ = origins; } #endif // ========== INTERNAL METHODS ========== @@ -273,6 +278,14 @@ class WebServer final : public Controller, public Component, public AsyncWebHand /// Handle an index request under '/'. void handle_index_request(AsyncWebServerRequest *request); +#ifdef USE_WEBSERVER_CAPTIVE + /// AP mode: DNS answers every name with the AP address and unknown URLs redirect to the interface. + /// wifi calls start before setup(), so it may touch nothing but dns_ (enable_loop is a no-op then). + void start_captive(); + void end_captive(); + bool is_captive() const { return this->dns_.is_running(); } +#endif + /// Return the webserver configuration as JSON. json::SerializationBuffer<> get_config_json(); @@ -292,22 +305,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 @@ -315,193 +328,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. @@ -522,6 +540,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 @@ -594,6 +615,10 @@ class WebServer final : public Controller, public Component, public AsyncWebHand #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif +#ifdef USE_WEBSERVER_CAPTIVE + void handle_not_found_(AsyncWebServerRequest *request); + web_server_base::CaptiveDNS dns_; +#endif #if USE_WEBSERVER_VERSION == 1 const char *css_url_{nullptr}; @@ -605,11 +630,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 @@ -619,79 +643,83 @@ 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 }; +#ifdef USE_WEBSERVER_CAPTIVE +extern WebServer *global_web_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif + } // namespace esphome::web_server #endif diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 546e10ff5e..1625153749 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -1,8 +1,9 @@ from pathlib import Path import esphome.codegen as cg +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, PlatformFramework from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.helpers import copy_file_if_changed @@ -10,6 +11,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network"] +DOMAIN = "web_server_base" def AUTO_LOAD() -> list[str]: @@ -26,6 +28,22 @@ WebServerBase = web_server_base_ns.class_("WebServerBase") CONF_WEB_SERVER_BASE_ID = "web_server_base_id" +def consume_captive_dns_sockets(config: ConfigType, name: str) -> None: + """Register the sockets a captive portal needs on top of the shared HTTP server: + 1 UDP socket for the DNS server and 3 TCP sockets for the OS captive portal probes, + which make several requests that linger in TIME_WAIT.""" + from esphome.components import socket + + socket.consume_sockets(3, name)(config) + socket.consume_sockets(1, name, socket.SocketType.UDP)(config) + + +def add_captive_dns_library() -> None: + """Pull in the Arduino DNSServer library used by CaptiveDNS off ESP32.""" + if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2): + cg.add_library("DNSServer", None) + + def _consume_web_server_base_sockets(config: ConfigType) -> ConfigType: """Register the shared listening socket for the HTTP server. @@ -81,3 +99,15 @@ async def to_code(config: ConfigType) -> None: cg.add_platformio_option("extra_scripts", ["pre:fix_rp2040_hash.py"]) # https://github.com/ESP32Async/ESPAsyncWebServer/blob/main/library.json cg.add_library("ESP32Async/ESPAsyncWebServer", "3.9.6") + + +# The DNS server used for captive portals on ESP32; other platforms use the Arduino +# DNSServer library. Its source is also guarded by USE_CAPTIVE_PORTAL / USE_WEBSERVER_CAPTIVE. +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "dns_server_esp32_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + } +) diff --git a/esphome/components/web_server_base/captive_dns.h b/esphome/components/web_server_base/captive_dns.h new file mode 100644 index 0000000000..0d027d3c40 --- /dev/null +++ b/esphome/components/web_server_base/captive_dns.h @@ -0,0 +1,63 @@ +#pragma once +#include "esphome/core/defines.h" +// DNS server that answers every name with the access point address, so a phone joining the +// AP runs its captive portal check against the device. Shared by captive_portal and the +// web_server AP mode; hides the ESP32 (own implementation) vs Arduino (DNSServer library) split. +#if defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE) +#include + +#include "esphome/components/network/ip_address.h" +#include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" +#if defined(USE_ESP32) +#include "dns_server_esp32_idf.h" +#elif defined(USE_ARDUINO) +#include +#endif + +namespace esphome::web_server_base { + +// The server object only exists while running, so an idle owner (AP not up) pays one pointer. +class CaptiveDNS { + public: + /// Returns false when the DNS server could not start; nothing is kept, so is_running() stays false. + bool start(const network::IPAddress &ip) { + if (this->dns_server_ != nullptr) + return true; + auto server = make_unique(); +#if defined(USE_ESP32) + const bool started = server->start(ip); +#elif defined(USE_ARDUINO) + server->setErrorReplyCode(DNSReplyCode::NoError); + const bool started = server->start(53, ESPHOME_F("*"), ip); +#endif + if (!started) + return false; + this->dns_server_ = std::move(server); + return true; + } + void stop() { + if (this->dns_server_ == nullptr) + return; + this->dns_server_->stop(); + this->dns_server_ = nullptr; + } + /// Answer one pending query; call from the owner's loop() while running. + void loop() { + if (this->dns_server_ == nullptr) + return; +#if defined(USE_ESP32) + this->dns_server_->process_next_request(); +#elif defined(USE_ARDUINO) + this->dns_server_->processNextRequest(); +#endif + } + bool is_running() const { return this->dns_server_ != nullptr; } + + protected: + // ESP32: web_server_base::DNSServer from dns_server_esp32_idf.h; Arduino: the library class. + std::unique_ptr dns_server_; +}; + +} // namespace esphome::web_server_base +#endif // USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.cpp b/esphome/components/web_server_base/dns_server_esp32_idf.cpp similarity index 93% rename from esphome/components/captive_portal/dns_server_esp32_idf.cpp rename to esphome/components/web_server_base/dns_server_esp32_idf.cpp index 56ad9f7176..c87433fa56 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.cpp +++ b/esphome/components/web_server_base/dns_server_esp32_idf.cpp @@ -1,5 +1,5 @@ #include "dns_server_esp32_idf.h" -#ifdef USE_ESP32 +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -7,9 +7,9 @@ #include #include -namespace esphome::captive_portal { +namespace esphome::web_server_base { -static const char *const TAG = "captive_portal.dns"; +ESPHOME_LOG_TAG(TAG, "web_server_base.dns"); // DNS constants static constexpr uint16_t DNS_PORT = 53; @@ -46,7 +46,7 @@ struct DNSAnswer { uint32_t ip_addr; } __attribute__((packed)); -void DNSServer::start(const network::IPAddress &ip) { +bool DNSServer::start(const network::IPAddress &ip) { this->server_ip_ = ip; #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; @@ -57,7 +57,7 @@ void DNSServer::start(const network::IPAddress &ip) { this->socket_ = socket::socket_ip_loop_monitored(SOCK_DGRAM, IPPROTO_UDP).release(); if (this->socket_ == nullptr) { ESP_LOGE(TAG, "Socket create failed"); - return; + return false; } // Set socket options @@ -72,9 +72,10 @@ void DNSServer::start(const network::IPAddress &ip) { if (err != 0) { ESP_LOGE(TAG, "Bind failed: %d", errno); this->destroy_socket_(); - return; + return false; } ESP_LOGV(TAG, "Bound to port %d", DNS_PORT); + return true; } void DNSServer::stop() { @@ -202,6 +203,6 @@ void DNSServer::process_next_request() { } } -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/captive_portal/dns_server_esp32_idf.h b/esphome/components/web_server_base/dns_server_esp32_idf.h similarity index 58% rename from esphome/components/captive_portal/dns_server_esp32_idf.h rename to esphome/components/web_server_base/dns_server_esp32_idf.h index b30856c204..2b10af332a 100644 --- a/esphome/components/captive_portal/dns_server_esp32_idf.h +++ b/esphome/components/web_server_base/dns_server_esp32_idf.h @@ -1,15 +1,18 @@ #pragma once -#ifdef USE_ESP32 +#include "esphome/core/defines.h" +// ESP32 backend for CaptiveDNS: answers every A query with the access point address. +#if defined(USE_ESP32) && (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) #include "esphome/core/helpers.h" #include "esphome/components/network/ip_address.h" #include "esphome/components/socket/socket.h" -namespace esphome::captive_portal { +namespace esphome::web_server_base { class DNSServer { public: - void start(const network::IPAddress &ip); + /// Returns false when the socket could not be created or bound. + bool start(const network::IPAddress &ip); void stop(); void process_next_request(); @@ -27,6 +30,6 @@ class DNSServer { uint8_t buffer_[DNS_BUFFER_SIZE]; }; -} // namespace esphome::captive_portal +} // namespace esphome::web_server_base -#endif // USE_ESP32 +#endif // USE_ESP32 && (USE_CAPTIVE_PORTAL || USE_WEBSERVER_CAPTIVE) diff --git a/esphome/components/web_server_idf/__init__.py b/esphome/components/web_server_idf/__init__.py index 5a400dfbf3..98e067665a 100644 --- a/esphome/components/web_server_idf/__init__.py +++ b/esphome/components/web_server_idf/__init__.py @@ -1,12 +1,13 @@ 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 from esphome.types import ConfigType CODEOWNERS = ["@dentra"] +DOMAIN = "web_server_idf" CONFIG_SCHEMA = cv.All( cv.Schema({}), @@ -16,10 +17,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/multipart.cpp b/esphome/components/web_server_idf/multipart.cpp index 7744272a5c..3b0da4fb56 100644 --- a/esphome/components/web_server_idf/multipart.cpp +++ b/esphome/components/web_server_idf/multipart.cpp @@ -8,7 +8,7 @@ namespace esphome::web_server_idf { -static const char *const TAG = "multipart"; +ESPHOME_LOG_TAG(TAG, "multipart"); // ========== MultipartReader Implementation ========== 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 0062556063..d9a7d65e8c 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -1,7 +1,9 @@ #ifdef USE_ESP32 +#include #include #include +#include #include #include #include @@ -10,11 +12,12 @@ #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 @@ -52,7 +55,13 @@ namespace esphome::web_server_idf { #define CRLF_STR "\r\n" #define CRLF_LEN (sizeof(CRLF_STR) - 1) -static const char *const TAG = "web_server_idf"; +ESPHOME_LOG_TAG(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. @@ -302,6 +311,10 @@ esp_err_t AsyncWebServer::handle_raw_body_(httpd_req_t *r, const char *content_t } AsyncWebServerRequest::~AsyncWebServerRequest() { +#ifdef USE_WEBSERVER_OTA + if (this->on_disconnect_) + this->on_disconnect_(); +#endif delete this->rsp_; for (auto *param : this->params_) { delete param; // NOLINT(cppcoreguidelines-owning-memory) @@ -326,9 +339,9 @@ StringRef AsyncWebServerRequest::url_to(std::span buffer) co return StringRef(buffer.data(), decoded_len); } -void AsyncWebServerRequest::redirect(const std::string &url) { +void AsyncWebServerRequest::redirect(const char *url) { httpd_resp_set_status(*this, "302 Found"); - httpd_resp_set_hdr(*this, "Location", url.c_str()); + httpd_resp_set_hdr(*this, "Location", url); httpd_resp_set_hdr(*this, "Connection", "close"); httpd_resp_send(*this, nullptr, 0); } @@ -545,14 +558,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; @@ -740,7 +757,8 @@ bool AsyncEventSource::loop() { ++i; } } - return !this->sessions_.empty(); + // A session still waiting for httpd to commit its context keeps the loop alive too + return !this->sessions_.empty() || this->has_pending_sessions_.load(std::memory_order_acquire); } void AsyncEventSource::adopt_pending_sessions_main_loop_() { @@ -751,18 +769,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. + // 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) @@ -792,7 +813,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); @@ -820,21 +841,31 @@ 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_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root["name"] = group.second.name; + root["name"] = json::linked(group.second.name); root["sorting_weight"] = group.second.weight; 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 @@ -871,8 +902,7 @@ void AsyncEventSourceResponse::process_deferred_queue_() { } 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; } @@ -888,8 +918,10 @@ void AsyncEventSourceResponse::request_close_() { if (!this->close_requested_) { this->close_requested_ = true; this->deferred_queue_.clear(); - this->event_buffer_.clear(); - this->event_bytes_sent_ = 0; + 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(); } @@ -940,21 +972,43 @@ void AsyncEventSourceResponse::close_session_work(void *arg) { response->close_work_queued_.store(false, std::memory_order_release); } -void AsyncEventSourceResponse::process_buffer_() { - if (this->close_requested_ || event_buffer_.empty()) { - return; +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; } - if (event_bytes_sent_ == event_buffer_.size()) { - event_buffer_.resize(0); - event_bytes_sent_ = 0; - return; + 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; +} - 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 +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(); @@ -968,32 +1022,62 @@ void AsyncEventSourceResponse::process_buffer_() { } return; } - if (bytes_sent == HTTPD_SOCK_ERR_FAIL) { - // Low-level asynchronous sends do not make HTTPD close the session automatically. - this->request_close_(); - return; - } - if (bytes_sent <= 0) { - // Unexpected error or zero bytes sent - ESP_LOGW(TAG, "Unexpected send result: %d", bytes_sent); - this->request_close_(); - return; - } - // Successful send - reset stall tracking this->send_failure_started_ms_ = 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()); + 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; +} - if (event_bytes_sent_ == event_buffer_.size()) { - event_buffer_.resize(0); - event_bytes_sent_ = 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() { @@ -1001,7 +1085,7 @@ void AsyncEventSourceResponse::loop() { this->process_close_(); return; } - process_buffer_(); + drain_tail_(); process_deferred_queue_(); if (this->close_requested_) return; @@ -1009,142 +1093,149 @@ void AsyncEventSourceResponse::loop() { 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 || this->close_requested_) { + if (!this->ready_to_send_()) { return false; } + SendGuard guard{*this}; - process_buffer_(); - if (this->close_requested_ || !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, @@ -1165,20 +1256,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 (this->close_requested_) { return; } - if (!event_buffer_.empty() || !deferred_queue_.empty()) { + 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 7894206102..4d4490d907 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 { @@ -126,7 +129,8 @@ class AsyncWebServerRequest { void requestAuthentication() const; #endif - void redirect(const std::string &url); + void redirect(const char *url); + void redirect(const std::string &url) { this->redirect(url.c_str()); } inline void ESPHOME_ALWAYS_INLINE send(AsyncWebServerResponse *response) { httpd_resp_send(*this, response->get_content_data(), response->get_content_size()); @@ -179,6 +183,12 @@ class AsyncWebServerRequest { std::string arg(const char *name); std::string arg(const std::string &name) { return this->arg(name.c_str()); } +#ifdef USE_WEBSERVER_OTA + /// Called when the request ends, including early returns. + // NOLINTNEXTLINE(readability-identifier-naming) + void onDisconnect(std::function fn) { this->on_disconnect_ = std::move(fn); } +#endif + operator httpd_req_t *() const { return this->req_; } optional get_header(const char *name) const; // NOLINTNEXTLINE(readability-identifier-naming) @@ -194,6 +204,9 @@ class AsyncWebServerRequest { optional find_query_value_(const char *name) const; std::vector params_; std::string post_query_; +#ifdef USE_WEBSERVER_OTA + std::function on_disconnect_; +#endif AsyncWebServerRequest(httpd_req_t *req) : req_(req) {} AsyncWebServerRequest(httpd_req_t *req, std::string post_query) : req_(req), post_query_(std::move(post_query)) {} void init_response_(AsyncWebServerResponse *rsp, int code, const char *content_type); @@ -255,7 +268,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,8 +313,30 @@ 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); @@ -317,15 +352,34 @@ 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_; + // 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}; - // Main-loop only; the HTTPD task never reads or writes this flag. - bool close_requested_{false}; - bool close_retry_warning_logged_{false}; + 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; @@ -335,7 +389,6 @@ using AsyncEventSourceClient = AsyncEventSourceResponse; class AsyncEventSource : public AsyncWebHandler { friend class AsyncEventSourceResponse; - using connect_handler_t = std::function; public: AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} @@ -350,11 +403,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); @@ -374,7 +422,6 @@ class AsyncEventSource : public AsyncWebHandler { // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_. std::vector pending_sessions_; Mutex pending_mutex_; - connect_handler_t on_connect_{}; esphome::web_server::WebServer *web_server_; std::atomic has_pending_sessions_{false}; }; diff --git a/esphome/components/weikai/__init__.py b/esphome/components/weikai/__init__.py index 8f0cf4ba33..58bfc6f815 100644 --- a/esphome/components/weikai/__init__.py +++ b/esphome/components/weikai/__init__.py @@ -17,6 +17,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] AUTO_LOAD = ["uart"] +DOMAIN = "weikai" MULTI_CONF = True CONF_CRYSTAL = "crystal" diff --git a/esphome/components/weikai/weikai.cpp b/esphome/components/weikai/weikai.cpp index b95d474fd3..938b191756 100644 --- a/esphome/components/weikai/weikai.cpp +++ b/esphome/components/weikai/weikai.cpp @@ -8,7 +8,7 @@ namespace esphome::weikai { -static const char *const TAG = "weikai"; +ESPHOME_LOG_TAG(TAG, "weikai"); /// @brief measure the time elapsed between two calls /// @param last_time time of the previous call @@ -254,7 +254,7 @@ size_t WeikaiGPIOPin::dump_summary(char *buffer, size_t len) const { void WeikaiChannel::setup_channel() { ESP_LOGCONFIG(TAG, " Setting up UART %s:%s", this->parent_->get_name(), this->get_channel_name()); // we enable transmit and receive on this channel - if (this->check_channel_down()) { + if (this->check_channel_down_()) { ESP_LOGCONFIG(TAG, " Error channel %s not working", this->get_channel_name()); } this->reset_fifo_(); @@ -378,7 +378,7 @@ size_t WeikaiChannel::rx_in_fifo_() { return available; } -bool WeikaiChannel::check_channel_down() { +bool WeikaiChannel::check_channel_down_() { // to check if we channel is up we write to the LCR W/R register // note that this will put a break on the tx line for few ms WeikaiRegister &lcr = this->reg(WKREG_LCR); diff --git a/esphome/components/weikai/weikai.h b/esphome/components/weikai/weikai.h index 02a39d3c84..eb8a41ad9d 100644 --- a/esphome/components/weikai/weikai.h +++ b/esphome/components/weikai/weikai.h @@ -266,7 +266,7 @@ class WeikaiComponent : public Component { /////////////////////////////////////////////////////////////////////////////// /// @brief Helper class to expose a WeiKai family IO pin as an internal GPIO pin. /////////////////////////////////////////////////////////////////////////////// -class WeikaiGPIOPin : public GPIOPin { +class WeikaiGPIOPin final : public GPIOPin { public: void set_parent(WeikaiComponent *parent) { this->parent_ = parent; } void set_pin(uint8_t pin) { this->pin_ = pin; } @@ -293,7 +293,7 @@ class WeikaiGPIOPin : public GPIOPin { /// uart::UARTComponent virtual class. This class is common to the different members of the Weikai /// components family and therefore avoid code duplication. /////////////////////////////////////////////////////////////////////////////////////////////////// -class WeikaiChannel : public uart::UARTComponent { +class WeikaiChannel final : public uart::UARTComponent { public: /// @brief We belongs to this WeikaiComponent /// @param parent pointer to the component we belongs to @@ -315,10 +315,10 @@ class WeikaiChannel : public uart::UARTComponent { const char *get_channel_name() { return this->name_.c_str(); } /// @brief Setup the channel - void virtual setup_channel(); + void setup_channel(); /// @brief dump channel information - void virtual dump_channel(); + void dump_channel(); /// @brief Factory method to create a WeikaiRegister proxy object /// @param reg address of the register @@ -423,7 +423,7 @@ class WeikaiChannel : public uart::UARTComponent { /// @brief check if channel is alive /// @return true if OK - bool virtual check_channel_down(); + bool check_channel_down_(); #ifdef TEST_COMPONENT /// @ingroup test_ diff --git a/esphome/components/weikai_i2c/__init__.py b/esphome/components/weikai_i2c/__init__.py index 2c6a421a0a..9bbe558dcf 100644 --- a/esphome/components/weikai_i2c/__init__.py +++ b/esphome/components/weikai_i2c/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@DrCoolZic"] +DOMAIN = "weikai_i2c" diff --git a/esphome/components/weikai_i2c/weikai_i2c.cpp b/esphome/components/weikai_i2c/weikai_i2c.cpp index af5bd7f313..bafa6c4e03 100644 --- a/esphome/components/weikai_i2c/weikai_i2c.cpp +++ b/esphome/components/weikai_i2c/weikai_i2c.cpp @@ -6,7 +6,7 @@ #include "weikai_i2c.h" namespace esphome::weikai_i2c { -static const char *const TAG = "weikai_i2c"; +ESPHOME_LOG_TAG(TAG, "weikai_i2c"); /// @brief Display a buffer in hexadecimal format (32 hex values / line). void print_buffer(const uint8_t *data, size_t length) { diff --git a/esphome/components/weikai_spi/__init__.py b/esphome/components/weikai_spi/__init__.py index 2c6a421a0a..2a13bda034 100644 --- a/esphome/components/weikai_spi/__init__.py +++ b/esphome/components/weikai_spi/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@DrCoolZic"] +DOMAIN = "weikai_spi" diff --git a/esphome/components/weikai_spi/weikai_spi.cpp b/esphome/components/weikai_spi/weikai_spi.cpp index 03bf66e07b..6a44fad6f3 100644 --- a/esphome/components/weikai_spi/weikai_spi.cpp +++ b/esphome/components/weikai_spi/weikai_spi.cpp @@ -7,7 +7,7 @@ namespace esphome::weikai_spi { using namespace weikai; -static const char *const TAG = "weikai_spi"; +ESPHOME_LOG_TAG(TAG, "weikai_spi"); /// @brief measure the time elapsed between two calls /// @param last_time time of the previous call diff --git a/esphome/components/whirlpool/__init__.py b/esphome/components/whirlpool/__init__.py index e69de29bb2..4bc63aeba8 100644 --- a/esphome/components/whirlpool/__init__.py +++ b/esphome/components/whirlpool/__init__.py @@ -0,0 +1 @@ +DOMAIN = "whirlpool" diff --git a/esphome/components/whirlpool/climate.py b/esphome/components/whirlpool/climate.py index 3435a6c0bf..efc7e6ca57 100644 --- a/esphome/components/whirlpool/climate.py +++ b/esphome/components/whirlpool/climate.py @@ -5,7 +5,7 @@ from esphome.const import CONF_MODEL from esphome.types import ConfigType AUTO_LOAD = ["climate_ir"] -CODEOWNERS = ["@glmnet"] +CODEOWNERS = ["@glmnet", "@arpena"] whirlpool_ns = cg.esphome_ns.namespace("whirlpool") WhirlpoolClimate = whirlpool_ns.class_("WhirlpoolClimate", climate_ir.ClimateIR) @@ -14,6 +14,7 @@ Model = whirlpool_ns.enum("Model") MODELS = { "DG11J1-3A": Model.MODEL_DG11J1_3A, "DG11J1-91": Model.MODEL_DG11J1_91, + "HISENSE_ARG": Model.MODEL_HISENSE_ARG, } CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).extend( @@ -26,3 +27,6 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(WhirlpoolClimate).ext async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) cg.add(var.set_model(config[CONF_MODEL])) + # cv.enum keeps the YAML key as a str subclass; the MockObj value cannot be compared + if config[CONF_MODEL] == "HISENSE_ARG": + cg.add_define("USE_WHIRLPOOL_HISENSE_ARG") diff --git a/esphome/components/whirlpool/whirlpool.cpp b/esphome/components/whirlpool/whirlpool.cpp index f560917f41..e341ce04bb 100644 --- a/esphome/components/whirlpool/whirlpool.cpp +++ b/esphome/components/whirlpool/whirlpool.cpp @@ -3,7 +3,7 @@ namespace esphome::whirlpool { -static const char *const TAG = "whirlpool.climate"; +ESPHOME_LOG_TAG(TAG, "whirlpool.climate"); const uint16_t WHIRLPOOL_HEADER_MARK = 9000; const uint16_t WHIRLPOOL_HEADER_SPACE = 4494; @@ -27,10 +27,20 @@ const uint8_t WHIRLPOOL_FAN_HIGH = 1; const uint8_t WHIRLPOOL_FAN_MED = 2; const uint8_t WHIRLPOOL_FAN_LOW = 3; -const uint8_t WHIRLPOOL_SWING_MASK = 128; +const uint8_t WHIRLPOOL_FAN_MASK = 0x03; + +const uint8_t WHIRLPOOL_SWING_MASK = 128; // byte 2 +const uint8_t WHIRLPOOL_SWING_BYTE8 = 0x40; // byte 8 const uint8_t WHIRLPOOL_POWER = 0x04; +// Hisense ARG model; unused constexpr values cost nothing in DG11J1-only builds +static constexpr uint8_t HISENSE_ARG_DISPLAY_ON = 0x80; // byte 11 +static constexpr uint8_t HISENSE_ARG_AMBIENT_TEMP = 0x18; // byte 12, 24 C +static constexpr uint8_t HISENSE_ARG_POWER_TOGGLE = 0x01; // byte 15 +static constexpr uint8_t HISENSE_ARG_SETTINGS_ONLY = 0x02; // byte 15 +static constexpr uint8_t HISENSE_ARG_FIXED_TEMP_STEP = 7; // auto and fan-only send 23 C + WhirlpoolClimate::WhirlpoolClimate() : climate_ir::ClimateIR( WHIRLPOOL_DG11J1_3A_TEMP_MIN, WHIRLPOOL_DG11J1_3A_TEMP_MAX, 1.0f, true, true, @@ -44,11 +54,11 @@ void WhirlpoolClimate::transmit_state() { remote_state[0] = 0x83; remote_state[1] = 0x06; remote_state[6] = 0x80; - // MODEL DG11J191 - remote_state[18] = 0x08; + remote_state[18] = 0x08; // the same on every supported model auto powered_on = this->mode != climate::CLIMATE_MODE_OFF; - if (powered_on != this->powered_on_assumed) { + const bool power_toggle = powered_on != this->powered_on_assumed; + if (power_toggle) { // Set power toggle command remote_state[2] = 4; remote_state[15] = 1; @@ -83,9 +93,16 @@ void WhirlpoolClimate::transmit_state() { break; } + const bool hisense_arg = this->is_hisense_arg_(); + // Temperature auto temp = (uint8_t) roundf(clamp(this->target_temperature, this->temperature_min_(), this->temperature_max_())); - remote_state[3] |= (uint8_t) (temp - this->temperature_min_()) << 4; + const auto temp_step = (uint8_t) (temp - this->temperature_min_()); + if (hisense_arg && this->fixed_temp_mode_()) { + remote_state[3] |= HISENSE_ARG_FIXED_TEMP_STEP << 4; + } else { + remote_state[3] |= temp_step << 4; + } // Fan speed switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { @@ -104,10 +121,17 @@ void WhirlpoolClimate::transmit_state() { // Swing ESP_LOGV(TAG, "send swing %s", this->send_swing_cmd_ ? LOG_STR_LITERAL("true") : LOG_STR_LITERAL("false")); - if (this->send_swing_cmd_) { + if (hisense_arg) { + // This model sends swing as a state on every frame; the DG11J1 remotes send a toggle + if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL) { + remote_state[2] |= WHIRLPOOL_SWING_MASK; + remote_state[8] = WHIRLPOOL_SWING_BYTE8; + } + this->encode_hisense_arg_(remote_state, power_toggle, temp_step); + } else if (this->send_swing_cmd_) { if (this->swing_mode == climate::CLIMATE_SWING_VERTICAL || this->swing_mode == climate::CLIMATE_SWING_OFF) { - remote_state[2] |= 128; - remote_state[8] |= 64; + remote_state[2] |= WHIRLPOOL_SWING_MASK; + remote_state[8] |= WHIRLPOOL_SWING_BYTE8; } } @@ -155,6 +179,49 @@ void WhirlpoolClimate::transmit_state() { transmit.perform(); } +bool WhirlpoolClimate::fixed_temp_mode_() const { + return this->mode == climate::CLIMATE_MODE_HEAT_COOL || this->mode == climate::CLIMATE_MODE_FAN_ONLY; +} + +void WhirlpoolClimate::encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const { +#ifdef USE_WHIRLPOOL_HISENSE_ARG + remote_state[11] = HISENSE_ARG_DISPLAY_ON; + remote_state[12] = HISENSE_ARG_AMBIENT_TEMP; + remote_state[15] = power_toggle ? HISENSE_ARG_POWER_TOGGLE : HISENSE_ARG_SETTINGS_ONLY; + + switch (this->mode) { + case climate::CLIMATE_MODE_OFF: + // The remote keeps the cool code in the power-off frame + remote_state[3] = (remote_state[3] & 0xF0) | WHIRLPOOL_COOL; + break; + case climate::CLIMATE_MODE_DRY: + // The unit runs these fan speeds itself; the user's choice is kept for the next mode change + remote_state[2] &= ~WHIRLPOOL_FAN_MASK; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + if ((remote_state[2] & WHIRLPOOL_FAN_MASK) == WHIRLPOOL_FAN_AUTO) { + remote_state[2] |= WHIRLPOOL_FAN_MED; + } + break; + case climate::CLIMATE_MODE_HEAT_COOL: + // Auto sends the target as one of four range codes in byte 2 bits 6:4 instead of the temperature nibble. + // Taken from the HPA-4911 firmware: 16 to 21 C, 22 C, 24 C, and everything else (23 C and 25 C up) share a code. + if (temp_step < 6) { + remote_state[2] |= 0x30; + } else if (temp_step == 6) { + remote_state[2] |= 0x50; + } else if (temp_step == 8) { + remote_state[2] |= 0x40; + } else { + remote_state[2] |= 0x20; + } + break; + default: + break; + } +#endif +} + bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { // Check if the esp isn't currently transmitting. if (millis() - this->last_transmit_time_ < 500) { @@ -265,16 +332,19 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { } } - // Set received temp - int temp = remote_state[3] & 0xF0; - ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); - temp = (uint8_t) temp >> 4; - temp += static_cast(this->temperature_min_()); - ESP_LOGVV(TAG, "Temperature Climate: %u", temp); - this->target_temperature = temp; + // Set received temp; Hisense ARG sends fixed values in auto and fan-only + const bool hisense_arg = this->is_hisense_arg_(); + if (!hisense_arg || !this->fixed_temp_mode_()) { + int temp = remote_state[3] & 0xF0; + ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); + temp = (uint8_t) temp >> 4; + temp += static_cast(this->temperature_min_()); + ESP_LOGVV(TAG, "Temperature Climate: %u", temp); + this->target_temperature = temp; + } // Set received fan speed - auto fan = remote_state[2] & 0x03; + auto fan = remote_state[2] & WHIRLPOOL_FAN_MASK; ESP_LOGVV(TAG, "Fan: %02X", fan); switch (fan) { case WHIRLPOOL_FAN_HIGH: @@ -293,7 +363,11 @@ bool WhirlpoolClimate::on_receive(remote_base::RemoteReceiveData data) { } // Set received swing status - if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && remote_state[8] == 0x40) { + if (hisense_arg) { + this->swing_mode = + (remote_state[8] & WHIRLPOOL_SWING_BYTE8) ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; + } else if ((remote_state[2] & WHIRLPOOL_SWING_MASK) == WHIRLPOOL_SWING_MASK && + remote_state[8] == WHIRLPOOL_SWING_BYTE8) { ESP_LOGVV(TAG, "Swing toggle pressed "); if (this->swing_mode == climate::CLIMATE_SWING_OFF) { this->swing_mode = climate::CLIMATE_SWING_VERTICAL; diff --git a/esphome/components/whirlpool/whirlpool.h b/esphome/components/whirlpool/whirlpool.h index b705ee95fa..8b5fc7519a 100644 --- a/esphome/components/whirlpool/whirlpool.h +++ b/esphome/components/whirlpool/whirlpool.h @@ -6,8 +6,9 @@ namespace esphome::whirlpool { /// Simple enum to represent models. enum Model { - MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32 - MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30 + MODEL_DG11J1_3A = 0, /// Temperature range is from 18 to 32 + MODEL_DG11J1_91 = 1, /// Temperature range is from 16 to 30 + MODEL_HISENSE_ARG = 2, /// Hisense/BGH (Argentina), 16 to 30 }; // Temperature @@ -46,10 +47,22 @@ class WhirlpoolClimate final : public climate_ir::ClimateIR { void transmit_state() override; /// Handle received IR Buffer bool on_receive(remote_base::RemoteReceiveData data) override; + /// False when the model is compiled out, so the compiler drops the Hisense ARG branches. + bool is_hisense_arg_() const { +#ifdef USE_WHIRLPOOL_HISENSE_ARG + return this->model_ == MODEL_HISENSE_ARG; +#else + return false; +#endif + } + /// Modes in which the Hisense ARG model sends a fixed temperature. + bool fixed_temp_mode_() const; + /// Add the Hisense ARG bytes on top of the common whirlpool frame. + void encode_hisense_arg_(uint8_t *remote_state, bool power_toggle, uint8_t temp_step) const; /// Set the time of the last transmission. uint32_t last_transmit_time_{}; - bool send_swing_cmd_{false}; + bool send_swing_cmd_{false}; // the DG11J1 remotes send swing as a toggle, only on the call that changed it Model model_; float temperature_min_() { diff --git a/esphome/components/whynter/__init__.py b/esphome/components/whynter/__init__.py index 7316776d78..f69112ca3d 100644 --- a/esphome/components/whynter/__init__.py +++ b/esphome/components/whynter/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@aeonsablaze"] +DOMAIN = "whynter" diff --git a/esphome/components/whynter/whynter.cpp b/esphome/components/whynter/whynter.cpp index f5f304aa17..5aee703feb 100644 --- a/esphome/components/whynter/whynter.cpp +++ b/esphome/components/whynter/whynter.cpp @@ -3,7 +3,7 @@ namespace esphome::whynter { -static const char *const TAG = "climate.whynter"; +ESPHOME_LOG_TAG(TAG, "climate.whynter"); const uint16_t BITS = 32; diff --git a/esphome/components/wiegand/__init__.py b/esphome/components/wiegand/__init__.py index dd6e84ac25..93d5e4f0f0 100644 --- a/esphome/components/wiegand/__init__.py +++ b/esphome/components/wiegand/__init__.py @@ -6,6 +6,7 @@ from esphome.const import CONF_ID, CONF_ON_KEY, CONF_ON_TAG, CONF_TRIGGER_ID from esphome.types import ConfigType CODEOWNERS = ["@ssieb"] +DOMAIN = "wiegand" AUTO_LOAD = ["key_provider"] diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index df64cd48aa..4f576e4c9d 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -5,7 +5,7 @@ namespace esphome::wiegand { -static const char *const TAG = "wiegand"; +ESPHOME_LOG_TAG(TAG, "wiegand"); static const char *const KEYS = "0123456789*#"; void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) { diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index c22d49e665..667e288fd7 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -12,6 +12,7 @@ from esphome.components.esp32 import ( get_esp32_variant, only_on_variant, request_wifi, + require_mbedtls_tls, require_mbedtls_tls_extras, ) from esphome.components.network import ( @@ -60,27 +61,27 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, - CONF_WIFI, PLACEHOLDER_WIFI_SSID, + Framework, Platform, PlatformFramework, ) from esphome.core import ( CORE, - ID, CoroPriority, EsphomeError, HexInt, coroutine_with_priority, ) import esphome.final_validate as fv -from esphome.types import ConfigType, TemplateArgsType +from esphome.types import ConfigType from . import wpa2_eap _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["network"] +DOMAIN = "wifi" NO_WIFI_VARIANTS = [ const.VARIANT_ESP32H2, @@ -344,6 +345,12 @@ def validate_variant(_): ) +def _validate_min_auth_mode(value: str) -> str: + if value == "WPA3" and CORE.is_esp8266: + raise cv.Invalid("ESP8266 does not support WPA3; use WPA or WPA2 instead") + return value + + def _apply_min_auth_mode_default(config): """Apply platform-specific default for min_auth_mode.""" if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): @@ -369,6 +376,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: @@ -483,6 +497,38 @@ def _fast_connect_schema(value: Any) -> ConfigType: return FAST_CONNECT_SCHEMA(value) +CONF_LEASE_CACHE = "lease_cache" +CONF_MAX_AGE = "max_age" + +LEASE_CACHE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ENABLED, default=True): cv.boolean, + # Defaults to RTC in _lease_cache_schema; flash wears and must be opted into. + cv.Optional(CONF_STORAGE): preferences.validate_storage, + cv.Optional(CONF_MAX_AGE): cv.positive_time_period_seconds, + } +) + + +def _lease_cache_schema(value: Any) -> ConfigType: + """Accept a plain boolean shorthand or a dict with enabled/storage/max_age keys.""" + if not isinstance(value, dict): + value = {CONF_ENABLED: value} + value = LEASE_CACHE_SCHEMA(value) + if value[CONF_ENABLED]: + cv.only_on_esp32(value) + cv.only_with_framework(Framework.ESP_IDF)(value) + if CONF_STORAGE not in value: + if not preferences.rtc_supported(): + raise cv.Invalid( + "This ESP32 variant has no RTC memory; set 'storage: flash' to " + "cache the lease in flash", + path=[CONF_STORAGE], + ) + value[CONF_STORAGE] = preferences.STORAGE_RTC + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -508,13 +554,15 @@ 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_LEASE_CACHE, default=False): _lease_cache_schema, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MIN_AUTH_MODE): cv.All( VALIDATE_WIFI_MIN_AUTH_MODE, cv.only_on([Platform.ESP32, Platform.ESP8266]), + _validate_min_auth_mode, ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) @@ -663,6 +711,10 @@ async def to_code(config): if (ap_timeout := conf[CONF_AP_TIMEOUT]) != cv.time_period(DEFAULT_AP_TIMEOUT): cg.add(var.set_ap_timeout(ap_timeout)) cg.add_define("USE_WIFI_AP") + # The LN882H radio cannot run the AP and STA together; the fallback AP + # takes turns with the networks instead. + if CORE.is_ln882x: + cg.add_define("USE_WIFI_AP_EXCLUSIVE") # ESP32: register the WiFi stack with the esp32 sdkconfig reconciler, which # drops SoftAP support / the LWIP DHCP server when AP mode is unused. @@ -673,10 +725,10 @@ async def to_code(config): if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) if has_eap: - # wpa_supplicant's EAP client negotiates with whatever the RADIUS - # server offers, and a failed handshake leaves the device off the - # network, so keep every mbedTLS client feature the esp32 platform - # would otherwise trim. + # The supplicant's Kconfig select cannot override the IDF 5 TLS + # role choice; the EAP client talks to mbedTLS directly (no + # esp-tls) and needs every trimmed extra. + require_mbedtls_tls() require_mbedtls_tls_extras() # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP @@ -712,6 +764,14 @@ async def to_code(config): # used flash (the in_flash flag was previously ignored outside ESP8266). if preferences.is_in_flash(fast_connect[CONF_STORAGE]): cg.add_define("USE_WIFI_FAST_CONNECT_IN_FLASH") + lease_cache = config[CONF_LEASE_CACHE] + if lease_cache[CONF_ENABLED]: + cg.add_define("USE_WIFI_LEASE_CACHE") + # is_in_flash() also emits USE_ESP32_RTC_PREFERENCES when rtc is chosen. + if preferences.is_in_flash(lease_cache[CONF_STORAGE]): + cg.add_define("USE_WIFI_LEASE_CACHE_IN_FLASH") + if (max_age := lease_cache.get(CONF_MAX_AGE)) is not None: + cg.add(var.set_lease_cache_max_age(int(max_age.total_seconds))) # passive_scan defaults to false in C++ - only set if true if config[CONF_PASSIVE_SCAN]: cg.add(var.set_passive_scan(True)) @@ -728,6 +788,10 @@ async def to_code(config): if not config[CONF_ENABLE_ON_BOOT]: cg.add(var.set_enable_on_boot(False)) + # LN882x: hand the SDK the BSSID LibreTiny 1.13 drops (see wifi_component_libretiny.cpp); remove once fixed upstream. + if CORE.is_ln882x: + cg.add_build_flag("-Wl,--wrap=wifi_sta_connect") + # post_connect_roaming defaults to true in C++ - disable if user disabled it # or if 802.11k/v is enabled (driver handles roaming natively) if ( @@ -772,7 +836,7 @@ async def to_code(config): "Applying high-performance WiFi settings (PSRAM guaranteed): 512 RX buffers, 32 TX buffers" ) # PSRAM is guaranteed - use aggressive settings - # Higher maximum values are allowed because CONFIG_LWIP_WND_SCALE is set to true in networking component + # Higher maximum values are allowed because CONFIG_LWIP_WND_SCALE may be set to true in networking component # Based on https://github.com/espressif/esp-adf/issues/297#issuecomment-783811702 # Large dynamic RX buffers (requires PSRAM) @@ -830,45 +894,49 @@ async def to_code(config): CORE.add_job(final_step) -@automation.register_condition("wifi.connected", WiFiConnectedCondition, cv.Schema({})) -async def wifi_connected_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_condition("wifi.enabled", WiFiEnabledCondition, cv.Schema({})) -async def wifi_enabled_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_condition("wifi.ap_active", WiFiAPActiveCondition, cv.Schema({})) -async def wifi_ap_active_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) - - -@automation.register_action( - "wifi.enable", WiFiEnableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.connected", + WiFiConnectedCondition, + cv.Schema({}), ) -async def wifi_enable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "wifi.disable", WiFiDisableAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.enabled", + WiFiEnabledCondition, + cv.Schema({}), ) -async def wifi_disable_to_code(config, action_id, template_arg, args): - return cg.new_Pvariable(action_id, template_arg) -@automation.register_action( - "wifi.roam", WiFiRoamAction, cv.Schema({}), synchronous=True +automation.register_bare_condition( + "wifi.ap_active", + WiFiAPActiveCondition, + cv.Schema({}), +) + + +automation.register_bare_action( + "wifi.enable", + WiFiEnableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "wifi.disable", + WiFiDisableAction, + cv.Schema({}), + synchronous=True, +) + + +automation.register_bare_action( + "wifi.roam", + WiFiRoamAction, + cv.Schema({}), + synchronous=True, ) -async def wifi_roam_to_code( - config: ConfigType, - action_id: ID, - template_arg: cg.TemplateArguments, - args: TemplateArgsType, -) -> cg.MockObj: - return cg.new_Pvariable(action_id, template_arg) KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" @@ -1069,7 +1137,7 @@ def _placeholder_wifi_credentials(config: ConfigType) -> list[str]: values still appear. Empty list means no placeholders were found. """ placeholders: list[str] = [] - wifi_conf = config.get(CONF_WIFI) + wifi_conf = config.get(DOMAIN) if not wifi_conf: return placeholders diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h index c14341330f..eb3d7471dc 100644 --- a/esphome/components/wifi/automation.h +++ b/esphome/components/wifi/automation.h @@ -76,13 +76,13 @@ template class WiFiConfigureAction final : public Action, // Enable WiFi global_wifi_component->enable(); // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { + this->set_timeout(CONNECT_TIMEOUT_ID, this->connection_timeout_.value(x...), [this, x...]() { // If the timeout is reached, stop connecting and revert to the old AP global_wifi_component->disable(); global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); global_wifi_component->enable(); // Start a timeout for the fallback if the connection to the old AP fails - this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { + this->set_timeout(FALLBACK_TIMEOUT_ID, this->connection_timeout_.value(x...), [this]() { this->connecting_ = false; this->error_trigger_.trigger(); }); @@ -97,8 +97,8 @@ template class WiFiConfigureAction final : public Action, return; if (global_wifi_component->is_connected()) { // The WiFi is connected, stop the timeout and reset the connecting flag - this->cancel_timeout("wifi-connect-timeout"); - this->cancel_timeout("wifi-fallback-timeout"); + this->cancel_timeout(CONNECT_TIMEOUT_ID); + this->cancel_timeout(FALLBACK_TIMEOUT_ID); this->connecting_ = false; char ssid_buf[SSID_BUFFER_SIZE]; if (strcmp(global_wifi_component->wifi_ssid_to(ssid_buf), this->new_sta_.get_ssid().c_str()) == 0) { @@ -112,6 +112,9 @@ template class WiFiConfigureAction final : public Action, } protected: + static constexpr uint32_t CONNECT_TIMEOUT_ID = 0; + static constexpr uint32_t FALLBACK_TIMEOUT_ID = 1; + bool connecting_{false}; WiFiAP new_sta_; WiFiAP old_sta_; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 125139ad16..62732e0915 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -36,6 +36,9 @@ #ifdef USE_CAPTIVE_PORTAL #include "esphome/components/captive_portal/captive_portal.h" #endif +#ifdef USE_WEBSERVER_CAPTIVE +#include "esphome/components/web_server/web_server.h" +#endif #ifdef USE_IMPROV_BLE #include "esphome/components/improv_ble/improv_ble_component.h" @@ -51,7 +54,7 @@ namespace esphome::wifi { -static const char *const TAG = "wifi"; +ESPHOME_LOG_TAG(TAG, "wifi"); // CompactString implementation CompactString::CompactString(const char *str, size_t len) { @@ -380,6 +383,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 @@ -634,14 +656,22 @@ void WiFiComponent::setup() { #endif #if defined(USE_PROVISIONING) && defined(USE_WIFI_AP) - // The access point is a provisioning surface: once the provisioning window has - // closed, shut it down (mirrors the teardown done on a successful connection). - // The captive portal registers its own closed-callback, and the fallback block - // in loop() is gated so neither is started again afterwards. + // The access point and the portals on it are provisioning surfaces: once the window has + // closed, end them (mirrors the teardown on a successful connection). The fallback block in + // loop() is gated so none of them is started again afterwards. if (provisioning::global_provisioning_manager != nullptr) { provisioning::global_provisioning_manager->add_on_closed_callback([this]() { + this->end_ap_portal_(); // no-op when nothing is active, so not tied to ap_setup_ 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); } }); @@ -674,6 +704,15 @@ void WiFiComponent::start() { this->fast_connect_pref_ = global_preferences->make_preference(hash + 1, fast_connect_in_flash); #endif +#ifdef USE_WIFI_LEASE_CACHE +#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH + const bool lease_cache_in_flash = true; +#else + const bool lease_cache_in_flash = false; +#endif + this->lease_cache_pref_ = + global_preferences->make_preference(hash + 2, lease_cache_in_flash); +#endif SavedWifiSettings save{}; if (this->pref_.load(&save)) { @@ -684,6 +723,10 @@ void WiFiComponent::start() { sta.set_password(save.password); this->set_sta(sta); } +#ifdef USE_WIFI_LEASE_CACHE + // After the saved credentials: set_sta() clears the cache, and the lease belongs to that same network + this->load_lease_settings_(); +#endif if (this->has_sta()) { this->wifi_sta_pre_setup_(); @@ -738,12 +781,13 @@ void WiFiComponent::start() { ESP_LOGV(TAG, "Setting Output Power Option failed"); } #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { + // Where the radio scans alongside the AP, the portal can list networks. + if (captive_portal::global_captive_portal != nullptr && !WIFI_AP_EXCLUSIVE) { this->wifi_sta_pre_setup_(); this->start_scanning(); - captive_portal::global_captive_portal->start(); } #endif + this->start_ap_portal_(); #endif // USE_WIFI_AP } #ifdef USE_IMPROV_BLE @@ -762,7 +806,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; } @@ -804,8 +848,8 @@ void WiFiComponent::loop() { this->check_connecting_finished(now); 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_improv_ble_active_(); + // Use longer cooldown when a portal/improv is active to avoid disrupting a user on the AP + bool portal_active = this->is_ap_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 @@ -862,8 +906,17 @@ void WiFiComponent::loop() { } break; } - case WIFI_COMPONENT_STATE_OFF: case WIFI_COMPONENT_STATE_AP: +#ifdef USE_WIFI_AP_EXCLUSIVE + // The networks are not tried while the AP is up, so it pauses for + // them once nobody has used it for a while. Without networks the AP + // is all there is and stays. + if (this->has_sta() && now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_DWELL_MS && + (this->ap_clients_ == 0 || now - this->ap_exclusive_changed_ > WIFI_AP_EXCLUSIVE_MAX_DWELL_MS)) + this->pause_exclusive_ap_(); + break; +#endif + case WIFI_COMPONENT_STATE_OFF: break; case WIFI_COMPONENT_STATE_DISABLED: return; @@ -879,17 +932,28 @@ 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_(); + // Where the AP runs on its own, a portal with no AP behind it would only stretch the cooldowns. + if (!WIFI_AP_EXCLUSIVE || this->ap_setup_) { #ifdef USE_CAPTIVE_PORTAL - if (captive_portal::global_captive_portal != nullptr) { // 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; - captive_portal::global_captive_portal->start(); - } #endif + this->start_ap_portal_(); + } } } #endif // USE_WIFI_AP @@ -972,40 +1036,39 @@ 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; if (this->ap_.ssid_.empty()) { - // Build AP SSID from app name without heap allocation - // WiFi SSID max is 32 bytes, with MAC suffix we keep first 25 + last 7 + // Build AP SSID from app name without heap allocation; WiFi SSID max is 32 bytes static constexpr size_t AP_SSID_MAX_LEN = 32; - static constexpr size_t AP_SSID_PREFIX_LEN = 25; - static constexpr size_t AP_SSID_SUFFIX_LEN = 7; const auto &app_name = App.get_name(); - const char *name_ptr = app_name.c_str(); - size_t name_len = app_name.length(); - - if (name_len <= AP_SSID_MAX_LEN) { + if (app_name.length() <= AP_SSID_MAX_LEN) { // Name fits, use directly - this->ap_.set_ssid(name_ptr); + this->ap_.set_ssid(app_name.c_str()); } else { - // Name too long, need to truncate into stack buffer char ssid_buf[AP_SSID_MAX_LEN + 1]; - if (App.is_name_add_mac_suffix_enabled()) { - // Keep first 25 chars and last 7 chars (MAC suffix), remove middle - memcpy(ssid_buf, name_ptr, AP_SSID_PREFIX_LEN); - memcpy(ssid_buf + AP_SSID_PREFIX_LEN, name_ptr + name_len - AP_SSID_SUFFIX_LEN, AP_SSID_SUFFIX_LEN); - } else { - memcpy(ssid_buf, name_ptr, AP_SSID_MAX_LEN); - } - ssid_buf[AP_SSID_MAX_LEN] = '\0'; + truncate_name_to(ssid_buf, sizeof(ssid_buf), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); this->ap_.set_ssid(ssid_buf); } } this->ap_setup_ = this->wifi_start_ap_(this->ap_); +#ifdef USE_WIFI_AP_EXCLUSIVE + // A failed start must not leave the radio in AP mode, which would refuse + // the STA attempts until the next try. + if (!this->ap_setup_) + this->wifi_mode_({}, false); +#endif char ip_buf[network::IP_ADDRESS_BUFFER_SIZE]; ESP_LOGCONFIG(TAG, @@ -1030,11 +1093,36 @@ 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_() { + this->end_ap_portal_(); + ESP_LOGD(TAG, "Disabling AP"); + this->wifi_mode_({}, false); +} + void WiFiComponent::set_ap(const WiFiAP &ap) { this->ap_ = ap; this->has_ap_ = true; @@ -1056,6 +1144,10 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->selected_sta_index_ = 0; // When new credentials are set (e.g., from improv), skip cooldown to retry immediately this->skip_cooldown_next_cycle_ = true; +#ifdef USE_WIFI_LEASE_CACHE + // Index 0 is a different network now, so its cached lease must not be re-applied + this->clear_lease_settings_(); +#endif } WiFiAP WiFiComponent::build_params_for_current_phase_() { @@ -1126,6 +1218,17 @@ void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) { } void WiFiComponent::connect_soon_() { +#ifdef USE_WIFI_AP_EXCLUSIVE + // New credentials from the portal: the AP pauses for them once its answer + // is out, and the STA state machine picks them up from there. + if (this->ap_setup_) { + this->set_timeout(AP_HANDOVER_TIMEOUT_ID, WIFI_AP_EXCLUSIVE_HANDOVER_MS, [this]() { + if (this->ap_setup_) + this->pause_exclusive_ap_(); + }); + return; + } +#endif // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); @@ -1218,8 +1321,9 @@ void WiFiComponent::start_connecting(const WiFiAP &ap) { // Clear any stale error from previous connection attempt. // This is the canonical location for clearing the flag since all connection - // attempts go through start_connecting(). The only other clear is in - // restart_adapter() which enters COOLDOWN without calling start_connecting(). + // attempts go through start_connecting(). restart_adapter() also clears it, as it + // enters COOLDOWN without calling start_connecting(), and ESP8266 clears it again + // after wifi_station_connect(), whose callbacks fire synchronously. this->error_from_callback_ = false; if (!this->wifi_sta_connect_(ap)) { @@ -1343,6 +1447,9 @@ void WiFiComponent::disable() { ESP_LOGD(TAG, "Disabling"); this->state_ = WIFI_COMPONENT_STATE_DISABLED; +#ifdef USE_WIFI_LEASE_CACHE + this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); +#endif this->wifi_disconnect_(); this->wifi_mode_(false, false); } @@ -1635,15 +1742,11 @@ void WiFiComponent::check_connecting_finished(uint32_t now) { // Reset to initial phase on successful connection (don't log transition, just reset state) this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; this->num_retried_ = 0; +#ifdef USE_WIFI_AP if (this->has_ap()) { -#ifdef USE_CAPTIVE_PORTAL - if (this->is_captive_portal_active_()) { - captive_portal::global_captive_portal->end(); - } -#endif - ESP_LOGD(TAG, "Disabling AP"); - this->wifi_mode_({}, false); + this->disable_ap_(); } +#endif #ifdef USE_IMPROV_BLE if (this->is_improv_ble_active_()) { improv_ble::global_improv_component->stop(); @@ -1966,10 +2069,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { break; case WiFiRetryPhase::RESTARTING_ADAPTER: - // Skip actual adapter restart if captive portal/improv is active + // Skip actual adapter restart if a 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_improv_ble_active_()) { + // without disrupting the portal/improv connection + if (!this->is_ap_portal_active_() && !this->is_improv_ble_active_()) { this->restart_adapter(); } else { // Even when skipping full restart, disconnect to clear driver state @@ -2228,6 +2331,36 @@ bool WiFiComponent::is_captive_portal_active_() { return false; #endif } + +bool WiFiComponent::is_ap_portal_active_() { +#ifdef USE_WEBSERVER_CAPTIVE + if (web_server::global_web_server->is_captive()) + return true; +#endif + return this->is_captive_portal_active_(); +} + +// global_web_server needs no null check: codegen always instantiates WebServer when +// USE_WEBSERVER_CAPTIVE is defined, and the constructor assigns the global. +void WiFiComponent::start_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (captive_portal::global_captive_portal != nullptr) + captive_portal::global_captive_portal->start(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->start_captive(); +#endif +} + +void WiFiComponent::end_ap_portal_() { +#ifdef USE_CAPTIVE_PORTAL + if (this->is_captive_portal_active_()) + captive_portal::global_captive_portal->end(); +#endif +#ifdef USE_WEBSERVER_CAPTIVE + web_server::global_web_server->end_captive(); +#endif +} bool WiFiComponent::is_improv_ble_active_() { #ifdef USE_IMPROV_BLE return improv_ble::global_improv_component != nullptr && improv_ble::global_improv_component->is_active(); @@ -2339,6 +2472,26 @@ void WiFiComponent::save_fast_connect_settings_(const bssid_t &bssid, uint8_t ch } #endif +#ifdef USE_WIFI_LEASE_CACHE +void WiFiComponent::load_lease_settings_() { + if (!this->lease_cache_pref_.load(&this->cached_lease_) || this->cached_lease_.version != SAVED_WIFI_LEASE_VERSION) { + this->cached_lease_ = {}; + return; + } + const uint8_t *ip = reinterpret_cast(&this->cached_lease_.ip); // network byte order + ESP_LOGD(TAG, "Loaded cached lease %u.%u.%u.%u (network %d, lease %us)", ip[0], ip[1], ip[2], ip[3], + this->cached_lease_.ap_index, this->cached_lease_.lease_secs); +} + +void WiFiComponent::clear_lease_settings_() { + if (this->cached_lease_.version == 0) + return; + this->cached_lease_ = {}; + this->lease_cache_pref_.save(&this->cached_lease_); + ESP_LOGD(TAG, "Lease cache: cleared"); +} +#endif + WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 6791379649..c1f4061a53 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -86,6 +86,25 @@ struct SavedWifiFastConnectSettings { int8_t ap_index; } PACKED; // NOLINT +#ifdef USE_WIFI_LEASE_CACHE +// Checked on load; a zeroed record (version 0) means no cached lease. +static constexpr uint8_t SAVED_WIFI_LEASE_VERSION = 1; + +// Cached DHCP lease. IPv4 fields in network byte order, times in seconds. +struct SavedWifiLeaseSettings { + uint8_t version; + int8_t ap_index; + uint32_t ip; + uint32_t netmask; + uint32_t gateway; + uint32_t dns1; + uint32_t dns2; + uint32_t lease_secs; + uint32_t t1_renew; + uint32_t saved_at; // RTC-domain clock at capture +} PACKED; // NOLINT +#endif + enum WiFiComponentState : uint8_t { /** Nothing has been initialized yet. Internal AP, if configured, is disabled at this point. */ WIFI_COMPONENT_STATE_OFF = 0, @@ -303,7 +322,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 { @@ -429,6 +448,10 @@ class WiFiPowerSaveListener { virtual void on_wifi_power_save(WiFiPowerSaveMode mode) = 0; }; +// Scheduler ids +static constexpr uint32_t AP_HANDOVER_TIMEOUT_ID = 0; +static constexpr uint32_t LEASE_RENEW_TIMEOUT_ID = 1; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent final : public Component { public: @@ -506,6 +529,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(); @@ -562,6 +588,9 @@ class WiFiComponent final : public Component { int8_t wifi_rssi(); void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } +#ifdef USE_WIFI_LEASE_CACHE + void set_lease_cache_max_age(uint32_t seconds) { this->lease_cache_max_age_ = seconds; } +#endif 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; } @@ -684,6 +713,12 @@ class WiFiComponent final : public Component { protected: #ifdef USE_WIFI_AP void setup_ap_config_(); + /// End the portals 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_(); @@ -797,6 +832,10 @@ class WiFiComponent final : public Component { network::IPAddress wifi_dns_ip_(int num); bool is_captive_portal_active_(); + /// captive_portal or the web_server AP mode is serving a user on the access point + bool is_ap_portal_active_(); + void start_ap_portal_(); + void end_ap_portal_(); bool is_improv_ble_active_(); #ifdef USE_WIFI_FAST_CONNECT @@ -804,6 +843,15 @@ class WiFiComponent final : public Component { void save_fast_connect_settings_(const bssid_t &bssid, uint8_t channel); #endif +#ifdef USE_WIFI_LEASE_CACHE + void load_lease_settings_(); + void save_lease_settings_(); + void clear_lease_settings_(); + // Cached lease belongs to the selected network and is still provably valid. + bool lease_valid_for_apply_() const; + void renew_cached_lease_(); +#endif + // Post-connect roaming methods void check_roaming_(uint32_t now); void process_roaming_scan_(); @@ -874,9 +922,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 @@ -893,6 +938,13 @@ class WiFiComponent final : public Component { #ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; #endif +#ifdef USE_WIFI_LEASE_CACHE + ESPPreferenceObject lease_cache_pref_; + SavedWifiLeaseSettings cached_lease_{}; // version 0 = none + // Current IP came from the cache (DHCP not run); suppresses saving until a real lease arrives. + bool applied_cached_lease_{false}; + uint32_t lease_cache_max_age_{0}; // 0 = no cap +#endif #ifdef USE_WIFI_CONNECT_TRIGGER Trigger<> connect_trigger_; #endif @@ -919,11 +971,17 @@ class WiFiComponent final : public Component { float output_power_{NAN}; uint32_t action_started_; uint32_t last_connected_{0}; +#ifdef USE_WIFI_AP_EXCLUSIVE + uint32_t ap_exclusive_changed_{0}; // When the AP was last started or paused +#endif uint32_t reboot_timeout_{900000}; // Keep in sync with DEFAULT_REBOOT_TIMEOUT in __init__.py uint32_t roaming_last_check_{0}; uint32_t roaming_scan_end_{0}; // Timestamp when last roaming scan completed #ifdef USE_WIFI_AP uint32_t ap_timeout_{90000}; // Keep in sync with DEFAULT_AP_TIMEOUT in __init__.py + // WiFiAP is byte aligned unless manual IP or EAP is enabled; placed before the + // 1-byte members so they pack into its trailing bytes instead of padding after it + WiFiAP ap_; #endif // 1-byte enums and integers @@ -983,7 +1041,8 @@ class WiFiComponent final : public Component { // Bools and bitfields // Pending listener callbacks deferred from platform callbacks to main loop. - struct { + // Empty when no listener needs deferring (e.g. ESP32 without connect state listeners) + [[no_unique_address]] struct { #ifdef USE_WIFI_CONNECT_STATE_LISTENERS // Deferred until state machine reaches STA_CONNECTED so wifi.connected // condition returns true in listener automations. @@ -1007,6 +1066,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..41b5146000 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -40,7 +40,7 @@ extern "C" { namespace esphome::wifi { -static const char *const TAG = "wifi_esp8266"; +ESPHOME_LOG_TAG(TAG, "wifi_esp8266"); enum class ESP8266WiFiSTAState : uint8_t { IDLE, // Not connecting @@ -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..bdca350dde 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -35,6 +35,12 @@ #include "lwip/apps/sntp.h" #include "lwip/dns.h" #include "lwip/err.h" +#ifdef USE_WIFI_LEASE_CACHE +#include // esp_netif_get_netif_impl() +#include // esp_rtc_get_time_us() -- RTC-domain clock for lease age +#include "lwip/dhcp.h" +#include "lwip/netif.h" +#endif #include "esphome/core/application.h" #include "esphome/core/hal.h" @@ -44,7 +50,7 @@ namespace esphome::wifi { -static const char *const TAG = "wifi_esp32"; +ESPHOME_LOG_TAG(TAG, "wifi_esp32"); static EventGroupHandle_t s_wifi_event_group; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static esp_netif_t *s_sta_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -553,6 +559,68 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return true; } +// sntp_servermode_dhcp() is an empty macro unless lwIP takes NTP servers from DHCP; that build needs the core lock +static void disable_dhcp_sntp() { +#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 + LwIPLock lock; +#endif + sntp_servermode_dhcp(false); +} + +#ifdef USE_WIFI_LEASE_CACHE +// Minimum lease time that must remain before a cached lease is applied. +static constexpr uint32_t LEASE_APPLY_MARGIN_SECS = 60; + +// Seconds since capture on the RTC-domain clock; UINT32_MAX if the clock was reset (age unknown). +static uint32_t lease_cache_age_secs(const SavedWifiLeaseSettings &lease) { + uint32_t now = static_cast(esp_rtc_get_time_us() / 1000000ULL); + return now >= lease.saved_at ? now - lease.saved_at : UINT32_MAX; +} + +// Renewal deadline (T1) from capture; half the lease when T1 is missing or invalid (RFC 2131). +static uint32_t lease_renew_at_secs(const SavedWifiLeaseSettings &lease) { + uint32_t renew_at = lease.t1_renew; + if (renew_at == 0 || renew_at >= lease.lease_secs) + renew_at = lease.lease_secs / 2; + return renew_at; +} + +// Starting the DHCP client drops the static address and DNS servers, so open connections reset +void WiFiComponent::renew_cached_lease_() { + ESP_LOGI(TAG, "Cached lease renew deadline reached; re-acquiring the address over DHCP, connections may drop"); + this->applied_cached_lease_ = false; + // Same SNTP leak guard as the DHCP path: https://github.com/esphome/issues/issues/2299 + disable_dhcp_sntp(); + esp_err_t err = esp_netif_dhcpc_start(s_sta_netif); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Lease renew: starting DHCP client failed: %s, retrying in 60 s", esp_err_to_name(err)); + this->set_timeout(LEASE_RENEW_TIMEOUT_ID, 60000, [this]() { this->renew_cached_lease_(); }); + } +} + +bool WiFiComponent::lease_valid_for_apply_() const { + const SavedWifiLeaseSettings &lease = this->cached_lease_; + if (lease.version != SAVED_WIFI_LEASE_VERSION || this->selected_sta_index_ < 0 || + lease.ap_index != this->selected_sta_index_) + return false; + // The age is only known when the RTC clock survived the reset (deep sleep or software reboot). + esp_reset_reason_t reason = esp_reset_reason(); + if (reason != ESP_RST_DEEPSLEEP && reason != ESP_RST_SW) + return false; + uint32_t age = lease_cache_age_secs(lease); + if (age == UINT32_MAX) + return false; + if (this->lease_cache_max_age_ != 0 && age > this->lease_cache_max_age_) + return false; + // Past T1 the lease needs DHCP renewal anyway. + if (age >= lease_renew_at_secs(lease)) + return false; + // Scale the margin with the lease: the RTC RC oscillator drifts a few percent. + uint32_t margin = std::max(LEASE_APPLY_MARGIN_SECS, lease.lease_secs / 16); + return lease.lease_secs > age + margin; +} +#endif + bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) @@ -571,18 +639,43 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { return false; } - if (!manual_ip.has_value()) { +#ifdef USE_WIFI_LEASE_CACHE + // Without a manual IP, a still-valid cached lease is applied as a static IP to skip DHCP. + optional effective_ip = manual_ip; + this->applied_cached_lease_ = false; + this->cancel_timeout(LEASE_RENEW_TIMEOUT_ID); + if (!effective_ip.has_value() && this->lease_valid_for_apply_()) { + const SavedWifiLeaseSettings &lease = this->cached_lease_; + esp_ip4_addr_t ip4{lease.ip}, mask4{lease.netmask}, gw4{lease.gateway}, dns1_4{lease.dns1}, dns2_4{lease.dns2}; + ManualIP cached; + cached.static_ip = network::IPAddress(&ip4); + cached.gateway = network::IPAddress(&gw4); + cached.subnet = network::IPAddress(&mask4); + cached.dns1 = network::IPAddress(&dns1_4); + cached.dns2 = network::IPAddress(&dns2_4); + effective_ip = cached; + this->applied_cached_lease_ = true; + + uint32_t age = lease_cache_age_secs(lease); + uint32_t remaining = lease.lease_secs > age ? lease.lease_secs - age : 0; + ESP_LOGI(TAG, "Applying cached lease " IPSTR ", ~%us left, skipping DHCP", IP2STR(&ip4), remaining); + + // Still awake at T1: switch to real DHCP. lease_valid_for_apply_() guarantees age < T1. + uint32_t renew_in = lease_renew_at_secs(lease) - age; + // Clamp below UINT32_MAX, which the scheduler treats as "don't run". + uint32_t renew_ms = + static_cast(std::min(static_cast(renew_in) * 1000ULL, UINT32_MAX - 1)); + this->set_timeout(LEASE_RENEW_TIMEOUT_ID, renew_ms, [this]() { this->renew_cached_lease_(); }); + } +#else + const optional &effective_ip = manual_ip; +#endif + + if (!effective_ip.has_value()) { // lwIP starts the SNTP client if it gets an SNTP server from DHCP. We don't need the time, and more importantly, // the built-in SNTP client has a memory leak in certain situations. Disable this feature. // https://github.com/esphome/issues/issues/2299 - { -#if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 - // sntp_servermode_dhcp() is an empty macro unless lwIP is built with - // DHCP-supplied NTP servers, so only that build needs the core lock. - LwIPLock lock; -#endif - sntp_servermode_dhcp(false); - } + disable_dhcp_sntp(); // No manual IP is set; use DHCP client if (dhcp_status != ESP_NETIF_DHCP_STARTED) { @@ -596,9 +689,9 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { } esp_netif_ip_info_t info; // struct of ip4_addr_t with ip, netmask, gw - info.ip = manual_ip->static_ip; - info.gw = manual_ip->gateway; - info.netmask = manual_ip->subnet; + info.ip = effective_ip->static_ip; + info.gw = effective_ip->gateway; + info.netmask = effective_ip->subnet; err = esp_netif_dhcpc_stop(s_sta_netif); if (err != ESP_OK && err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { ESP_LOGV(TAG, "Stopping DHCP client failed: %s", esp_err_to_name(err)); @@ -610,18 +703,82 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { } esp_netif_dns_info_t dns; - if (manual_ip->dns1.is_set()) { - dns.ip = manual_ip->dns1; + if (effective_ip->dns1.is_set()) { + dns.ip = effective_ip->dns1; esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns); } - if (manual_ip->dns2.is_set()) { - dns.ip = manual_ip->dns2; + if (effective_ip->dns2.is_set()) { + dns.ip = effective_ip->dns2; esp_netif_set_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns); } return true; } +#ifdef USE_WIFI_LEASE_CACHE +void WiFiComponent::save_lease_settings_() { + // A cached lease applied as a static IP has no DHCP timing to capture. + if (s_sta_netif == nullptr || this->applied_cached_lease_) + return; + +#ifdef USE_WIFI_MANUAL_IP + if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) + return; +#endif + + esp_netif_ip_info_t info; + if (esp_netif_get_ip_info(s_sta_netif, &info) != ESP_OK || info.ip.addr == 0) + return; + + SavedWifiLeaseSettings lease{}; + lease.version = SAVED_WIFI_LEASE_VERSION; + lease.ap_index = this->selected_sta_index_; + lease.ip = info.ip.addr; + lease.netmask = info.netmask.addr; + lease.gateway = info.gw.addr; + + esp_netif_dns_info_t dns; + if (esp_netif_get_dns_info(s_sta_netif, ESP_NETIF_DNS_MAIN, &dns) == ESP_OK && dns.ip.type == ESP_IPADDR_TYPE_V4) { + lease.dns1 = dns.ip.u_addr.ip4.addr; + } + if (esp_netif_get_dns_info(s_sta_netif, ESP_NETIF_DNS_BACKUP, &dns) == ESP_OK && dns.ip.type == ESP_IPADDR_TYPE_V4) { + lease.dns2 = dns.ip.u_addr.ip4.addr; + } + + // Lease timing is only in lwIP's internal DHCP state; without it the lease can't be aged. + bool have_timing = false; + if (auto *impl = static_cast(esp_netif_get_netif_impl(s_sta_netif))) { + LwIPLock lock; + if (struct dhcp *d = netif_dhcp_data(impl)) { + lease.lease_secs = d->offered_t0_lease; + lease.t1_renew = d->offered_t1_renew; + have_timing = true; + } + } + if (!have_timing) + return; + + lease.saved_at = static_cast(esp_rtc_get_time_us() / 1000000ULL); + + // On flash, skip a write that would only bump saved_at (an older one only overstates the age), + // unless it is from a previous RTC clock period (power cycle). +#ifdef USE_WIFI_LEASE_CACHE_IN_FLASH + constexpr size_t compare_len = offsetof(SavedWifiLeaseSettings, saved_at); +#else + constexpr size_t compare_len = sizeof(SavedWifiLeaseSettings); +#endif + const SavedWifiLeaseSettings &previous = this->cached_lease_; + if (previous.saved_at <= lease.saved_at && memcmp(&previous, &lease, compare_len) == 0) + return; + + this->lease_cache_pref_.save(&lease); + this->cached_lease_ = lease; + + ESP_LOGD(TAG, "Lease cache: saved " IPSTR " (network %d, lease %us)", IP2STR(&info.ip), lease.ap_index, + lease.lease_secs); +} +#endif + esp_netif_t *WiFiComponent::get_esp_netif_sta() { return s_sta_netif; } network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { @@ -829,6 +986,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. @@ -847,9 +1010,17 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { // This ensures wifi.connected condition returns true in listener automations this->pending_.connect_state = true; #endif - // For static IP configurations, GOT_IP event may not fire, so notify IP listeners here -#if defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP) - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) { + // For static IP configurations (manual or cached lease), GOT_IP event may not fire, so notify IP listeners here +#if defined(USE_WIFI_IP_STATE_LISTENERS) && (defined(USE_WIFI_MANUAL_IP) || defined(USE_WIFI_LEASE_CACHE)) + bool static_ip = false; +#ifdef USE_WIFI_MANUAL_IP + const WiFiAP *config = this->get_selected_sta_(); + static_ip = config && config->get_manual_ip().has_value(); +#endif +#ifdef USE_WIFI_LEASE_CACHE + static_ip = static_ip || this->applied_cached_lease_; +#endif + if (static_ip) { this->notify_ip_state_listeners_(); } #endif @@ -881,11 +1052,11 @@ 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_LEASE_CACHE + this->save_lease_settings_(); // events are drained on the main loop +#endif #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif @@ -1163,21 +1334,18 @@ bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { return false; } -#if defined(USE_CAPTIVE_PORTAL) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) - // Configure DHCP Option 114 (Captive Portal URI) if captive portal is enabled - // This provides a standards-compliant way for clients to discover the captive portal - if (captive_portal::global_captive_portal != nullptr) { - // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy - static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null - memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates - network::IPAddress(&info.ip).str_to(captive_portal_uri + 7); - err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, - strlen(captive_portal_uri)); - if (err != ESP_OK) { - ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); - } else { - ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); - } +#if (defined(USE_CAPTIVE_PORTAL) || defined(USE_WEBSERVER_CAPTIVE)) && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 4, 0) + // DHCP option 114 (captive portal URI) lets clients find the portal; either define means one exists + // Buffer must be static - dhcps_set_option_info stores pointer, doesn't copy + static char captive_portal_uri[24]; // "http://" (7) + IPv4 max (15) + null + memcpy(captive_portal_uri, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates + network::IPAddress(&info.ip).str_to(captive_portal_uri + 7); + err = esp_netif_dhcps_option(s_ap_netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captive_portal_uri, + strlen(captive_portal_uri)); + if (err != ESP_OK) { + ESP_LOGV(TAG, "Failed to set DHCP captive portal URI: %s", esp_err_to_name(err)); + } else { + ESP_LOGV(TAG, "DHCP Captive Portal URI set to: %s", captive_portal_uri); } #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 940f2a0783..ca32f3d3ff 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" @@ -36,7 +52,7 @@ extern "C" { namespace esphome::wifi { -static const char *const TAG = "wifi_lt"; +ESPHOME_LOG_TAG(TAG, "wifi_lt"); // Thread-safe event handling for LibreTiny WiFi // @@ -236,9 +252,14 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { this->sta_state_ = static_cast(LTWiFiSTAState::CONNECTING); s_ignored_disconnect_count = 0; - WiFiStatus status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), - ap.get_channel(), // 0 = auto - ap.has_bssid() ? ap.get_bssid().data() : NULL); + WiFiStatus status; + { + // The driver blocks until the join has started, or finished on Realtek + UnavoidableBlockingScope scope; + status = WiFi.begin(ap.ssid_.c_str(), ap.password_.empty() ? NULL : ap.password_.c_str(), + ap.get_channel(), // 0 = auto + ap.has_bssid() ? ap.get_bssid().data() : NULL); + } if (status != WL_CONNECTED) { ESP_LOGW(TAG, "WiFi.begin failed: %d", status); // Without this reset the state machine stays at CONNECTING and each retry @@ -608,6 +629,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 +641,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 +798,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 +812,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/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index 325bcf2652..5b05132f4f 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -18,7 +18,7 @@ namespace esphome::wifi { -static const char *const TAG = "wifi_pico_w"; +ESPHOME_LOG_TAG(TAG, "wifi_pico_w"); // Check if STA is fully connected (WiFi joined + has IP address). // Do NOT use WiFi.status() or WiFi.connected() for this — in AP-only mode they diff --git a/esphome/components/wifi_info/__init__.py b/esphome/components/wifi_info/__init__.py index e69de29bb2..77d5dd714a 100644 --- a/esphome/components/wifi_info/__init__.py +++ b/esphome/components/wifi_info/__init__.py @@ -0,0 +1 @@ +DOMAIN = "wifi_info" diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index 5d4e77eaad..98b0cc4814 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -9,7 +9,7 @@ namespace esphome::wifi_info { -static const char *const TAG = "wifi_info"; +ESPHOME_LOG_TAG(TAG, "wifi_info"); #ifdef USE_WIFI_IP_STATE_LISTENERS diff --git a/esphome/components/wifi_signal/__init__.py b/esphome/components/wifi_signal/__init__.py index e69de29bb2..febfbf7387 100644 --- a/esphome/components/wifi_signal/__init__.py +++ b/esphome/components/wifi_signal/__init__.py @@ -0,0 +1 @@ +DOMAIN = "wifi_signal" diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.cpp b/esphome/components/wifi_signal/wifi_signal_sensor.cpp index 11d816a909..57cbf9ae9b 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.cpp +++ b/esphome/components/wifi_signal/wifi_signal_sensor.cpp @@ -4,7 +4,7 @@ namespace esphome::wifi_signal { -static const char *const TAG = "wifi_signal.sensor"; +ESPHOME_LOG_TAG(TAG, "wifi_signal.sensor"); void WiFiSignalSensor::dump_config() { LOG_SENSOR("", "WiFi Signal", this); } diff --git a/esphome/components/wireguard/__init__.py b/esphome/components/wireguard/__init__.py index ea9e5a3b0c..e9641a5957 100644 --- a/esphome/components/wireguard/__init__.py +++ b/esphome/components/wireguard/__init__.py @@ -33,6 +33,7 @@ CONF_WIREGUARD_ID = "wireguard_id" DEPENDENCIES = ["time"] CODEOWNERS = ["@lhoracek", "@droscy", "@thomas0bernard"] +DOMAIN = "wireguard" # The key validation regex has been described by Jason Donenfeld himself # url: https://lists.zx2c4.com/pipermail/wireguard/2020-December/006222.html @@ -41,16 +42,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): @@ -130,16 +121,19 @@ async def to_code(config): ) ) + # Shared flash table ended by an empty entry, so the component stores only a pointer. + entries = [ + cg.StructInitializer( + AllowedIP, + ("ip", str(ip.network_address)), + ("netmask", str(ip.netmask)), + ) + for ip in allowed_ips + ] + end = cg.StructInitializer(AllowedIP, ("ip", cg.nullptr), ("netmask", cg.nullptr)) cg.add( var.set_allowed_ips( - [ - cg.StructInitializer( - AllowedIP, - ("ip", str(ip.network_address)), - ("netmask", str(ip.netmask)), - ) - for ip in allowed_ips - ] + cg.shared_progmem_array("wireguard_allowed_ips", AllowedIP, [*entries, end]) ) ) @@ -153,57 +147,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..01921f17e8 100644 --- a/esphome/components/wireguard/wireguard.cpp +++ b/esphome/components/wireguard/wireguard.cpp @@ -14,7 +14,7 @@ namespace esphome::wireguard { -static const char *const TAG = "wireguard"; +ESPHOME_LOG_TAG(TAG, "wireguard"); /* * Cannot use `static const char*` for LOGMSG_PEER_STATUS on esp8266 platform @@ -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_(); } @@ -149,8 +154,8 @@ void Wireguard::dump_config() { (this->preshared_key_ != nullptr ? preshared_key_masked : LOG_STR_LITERAL("NOT IN USE"))); // clang-format on ESP_LOGCONFIG(TAG, " Peer Allowed IPs:"); - for (const AllowedIP &allowed_ip : this->allowed_ips_) { - ESP_LOGCONFIG(TAG, " - %s/%s", allowed_ip.ip, allowed_ip.netmask); + for (const AllowedIP *it = this->allowed_ips_; it != nullptr && it->ip != nullptr; it++) { + ESP_LOGCONFIG(TAG, " - %s/%s", it->ip, it->netmask); } ESP_LOGCONFIG(TAG, " Peer Persistent Keepalive: %d%s", this->keepalive_, (this->keepalive_ > 0 ? LOG_STR_LITERAL("s") : LOG_STR_LITERAL(" (DISABLED)"))); @@ -244,8 +249,8 @@ void Wireguard::start_connection_() { ESP_LOGD(TAG, "Configuring allowed IPs list"); bool allowed_ips_ok = true; - for (const AllowedIP &ip : this->allowed_ips_) { - allowed_ips_ok &= (esp_wireguard_add_allowed_ip(&(this->wg_ctx_), ip.ip, ip.netmask) == ESP_OK); + for (const AllowedIP *it = this->allowed_ips_; it != nullptr && it->ip != nullptr; it++) { + allowed_ips_ok &= (esp_wireguard_add_allowed_ip(&(this->wg_ctx_), it->ip, it->netmask) == ESP_OK); } if (allowed_ips_ok) { diff --git a/esphome/components/wireguard/wireguard.h b/esphome/components/wireguard/wireguard.h index c9c2feb7ae..6125d2f52c 100644 --- a/esphome/components/wireguard/wireguard.h +++ b/esphome/components/wireguard/wireguard.h @@ -2,9 +2,7 @@ #include "esphome/core/defines.h" #ifdef USE_WIREGUARD #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" @@ -59,9 +57,8 @@ class Wireguard final : public PollingComponent { void set_peer_public_key(const std::string &key) = delete; void set_preshared_key(const std::string &key) = delete; - void set_allowed_ips(std::initializer_list ips) { this->allowed_ips_ = ips; } - /// Prevent accidental use of std::string which would dangle - void set_allowed_ips(std::initializer_list> ips) = delete; + /// Table ended by an entry with a null ip; must outlive the component. + void set_allowed_ips(const AllowedIP *ips) { this->allowed_ips_ = ips; } void set_keepalive(const uint16_t seconds) { this->keepalive_ = seconds; } void set_reboot_timeout(const uint32_t seconds) { this->reboot_timeout_ = seconds; } @@ -106,7 +103,7 @@ class Wireguard final : public PollingComponent { const char *peer_public_key_{nullptr}; const char *preshared_key_{nullptr}; - FixedVector allowed_ips_; + const AllowedIP *allowed_ips_{nullptr}; uint16_t peer_port_; uint16_t keepalive_; @@ -141,6 +138,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 +158,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/wk2132_i2c/__init__.py b/esphome/components/wk2132_i2c/__init__.py index ecb7a5da09..7e0999b08f 100644 --- a/esphome/components/wk2132_i2c/__init__.py +++ b/esphome/components/wk2132_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["weikai", "weikai_i2c"] +DOMAIN = "wk2132_i2c" MULTI_CONF = True weikai_i2c_ns = cg.esphome_ns.namespace("weikai_i2c") diff --git a/esphome/components/wk2132_spi/__init__.py b/esphome/components/wk2132_spi/__init__.py index 94b692f170..de482839e1 100644 --- a/esphome/components/wk2132_spi/__init__.py +++ b/esphome/components/wk2132_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["weikai", "weikai_spi"] +DOMAIN = "wk2132_spi" MULTI_CONF = True weikai_spi_ns = cg.esphome_ns.namespace("weikai_spi") diff --git a/esphome/components/wk2168_i2c/__init__.py b/esphome/components/wk2168_i2c/__init__.py index d4aacaf97d..c450717ea2 100644 --- a/esphome/components/wk2168_i2c/__init__.py +++ b/esphome/components/wk2168_i2c/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["weikai", "weikai_i2c"] +DOMAIN = "wk2168_i2c" MULTI_CONF = True CONF_WK2168_I2C = "wk2168_i2c" diff --git a/esphome/components/wk2168_spi/__init__.py b/esphome/components/wk2168_spi/__init__.py index b4efaf65ac..ef12117a2c 100644 --- a/esphome/components/wk2168_spi/__init__.py +++ b/esphome/components/wk2168_spi/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["weikai", "weikai_spi"] +DOMAIN = "wk2168_spi" MULTI_CONF = True CONF_WK2168_SPI = "wk2168_spi" diff --git a/esphome/components/wk2204_i2c/__init__.py b/esphome/components/wk2204_i2c/__init__.py index 1502cc6cdb..b8882d41c1 100644 --- a/esphome/components/wk2204_i2c/__init__.py +++ b/esphome/components/wk2204_i2c/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["weikai", "weikai_i2c"] +DOMAIN = "wk2204_i2c" MULTI_CONF = True weikai_i2c_ns = cg.esphome_ns.namespace("weikai_i2c") diff --git a/esphome/components/wk2204_spi/__init__.py b/esphome/components/wk2204_spi/__init__.py index 930f534bb0..2de911aebd 100644 --- a/esphome/components/wk2204_spi/__init__.py +++ b/esphome/components/wk2204_spi/__init__.py @@ -7,6 +7,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["weikai", "weikai_spi"] +DOMAIN = "wk2204_spi" MULTI_CONF = True weikai_spi_ns = cg.esphome_ns.namespace("weikai_spi") diff --git a/esphome/components/wk2212_i2c/__init__.py b/esphome/components/wk2212_i2c/__init__.py index 00018dd735..1f51fea314 100644 --- a/esphome/components/wk2212_i2c/__init__.py +++ b/esphome/components/wk2212_i2c/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["i2c"] AUTO_LOAD = ["weikai", "weikai_i2c"] +DOMAIN = "wk2212_i2c" MULTI_CONF = True CONF_WK2212_I2C = "wk2212_i2c" diff --git a/esphome/components/wk2212_spi/__init__.py b/esphome/components/wk2212_spi/__init__.py index c1a050e8ca..a0cce3331a 100644 --- a/esphome/components/wk2212_spi/__init__.py +++ b/esphome/components/wk2212_spi/__init__.py @@ -9,6 +9,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@DrCoolZic"] DEPENDENCIES = ["spi"] AUTO_LOAD = ["weikai", "weikai_spi"] +DOMAIN = "wk2212_spi" MULTI_CONF = True CONF_WK2212_SPI = "wk2212_spi" diff --git a/esphome/components/wl_134/__init__.py b/esphome/components/wl_134/__init__.py index e69de29bb2..03fae78280 100644 --- a/esphome/components/wl_134/__init__.py +++ b/esphome/components/wl_134/__init__.py @@ -0,0 +1 @@ +DOMAIN = "wl_134" diff --git a/esphome/components/wl_134/wl_134.cpp b/esphome/components/wl_134/wl_134.cpp index 858f974f2b..745f9ea3e6 100644 --- a/esphome/components/wl_134/wl_134.cpp +++ b/esphome/components/wl_134/wl_134.cpp @@ -6,7 +6,7 @@ namespace esphome::wl_134 { -static const char *const TAG = "wl_134.sensor"; +ESPHOME_LOG_TAG(TAG, "wl_134.sensor"); static const uint8_t ASCII_CR = 0x0D; static const uint8_t ASCII_NBSP = 0xFF; static const int MAX_DATA_LENGTH_BYTES = 6; diff --git a/esphome/components/wled/__init__.py b/esphome/components/wled/__init__.py index a7135975f0..6b3263e6a3 100644 --- a/esphome/components/wled/__init__.py +++ b/esphome/components/wled/__init__.py @@ -6,6 +6,8 @@ from esphome.const import CONF_NAME, CONF_PORT from esphome.core import CORE, ID from esphome.types import ConfigType +DOMAIN = "wled" + wled_ns = cg.esphome_ns.namespace("wled") WLEDLightEffect = wled_ns.class_("WLEDLightEffect", AddressableLightEffect) diff --git a/esphome/components/wled/wled_light_effect.cpp b/esphome/components/wled/wled_light_effect.cpp index 5150cda2a5..da0accc9a9 100644 --- a/esphome/components/wled/wled_light_effect.cpp +++ b/esphome/components/wled/wled_light_effect.cpp @@ -25,7 +25,7 @@ enum Protocol { WLED_NOTIFIER = 0, WARLS = 1, DRGB = 2, DRGBW = 3, DNRGB = 4 }; constexpr uint32_t DEFAULT_BLANK_TIME = 1000; -static const char *const TAG = "wled_light_effect"; +ESPHOME_LOG_TAG(TAG, "wled_light_effect"); WLEDLightEffect::WLEDLightEffect(const char *name) : AddressableLightEffect(name) {} diff --git a/esphome/components/wts01/__init__.py b/esphome/components/wts01/__init__.py index e69de29bb2..5d5a06ea05 100644 --- a/esphome/components/wts01/__init__.py +++ b/esphome/components/wts01/__init__.py @@ -0,0 +1 @@ +DOMAIN = "wts01" diff --git a/esphome/components/wts01/wts01.cpp b/esphome/components/wts01/wts01.cpp index cc7ee98079..b7b9e5cfa9 100644 --- a/esphome/components/wts01/wts01.cpp +++ b/esphome/components/wts01/wts01.cpp @@ -9,7 +9,7 @@ constexpr uint8_t HEADER_2 = 0x01; constexpr uint8_t HEADER_3 = 0x01; constexpr uint8_t HEADER_4 = 0x04; -static const char *const TAG = "wts01"; +ESPHOME_LOG_TAG(TAG, "wts01"); void WTS01Sensor::loop() { // Process all available data at once @@ -58,10 +58,7 @@ void WTS01Sensor::process_packet_() { // Based on Tasmota implementation // Format: 55 01 01 04 01 11 16 12 95 // header T Td Ck - T = Temperature, Td = Temperature decimal, Ck = Checksum - uint8_t calculated_checksum = 0; - for (uint8_t i = 0; i < PACKET_SIZE - 1; i++) { - calculated_checksum += this->buffer_[i]; - } + const uint8_t calculated_checksum = sum_bytes(this->buffer_, PACKET_SIZE - 1); uint8_t received_checksum = this->buffer_[PACKET_SIZE - 1]; if (calculated_checksum != received_checksum) { diff --git a/esphome/components/x9c/__init__.py b/esphome/components/x9c/__init__.py index e69de29bb2..46a7316bd1 100644 --- a/esphome/components/x9c/__init__.py +++ b/esphome/components/x9c/__init__.py @@ -0,0 +1 @@ +DOMAIN = "x9c" diff --git a/esphome/components/x9c/x9c.cpp b/esphome/components/x9c/x9c.cpp index b0ad79e51c..a9e9e9cdae 100644 --- a/esphome/components/x9c/x9c.cpp +++ b/esphome/components/x9c/x9c.cpp @@ -3,7 +3,7 @@ namespace esphome::x9c { -static const char *const TAG = "x9c.output"; +ESPHOME_LOG_TAG(TAG, "x9c.output"); void X9cOutput::trim_value(int32_t change_amount) { if (change_amount == 0) { diff --git a/esphome/components/xdb401/__init__.py b/esphome/components/xdb401/__init__.py index 943139e19a..181b38caca 100644 --- a/esphome/components/xdb401/__init__.py +++ b/esphome/components/xdb401/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@RT530"] +DOMAIN = "xdb401" 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..5539a1318f 100644 --- a/esphome/components/xdb401/xdb401.cpp +++ b/esphome/components/xdb401/xdb401.cpp @@ -4,7 +4,7 @@ namespace esphome::xdb401 { -static const char *const TAG = "xdb401"; +ESPHOME_LOG_TAG(TAG, "xdb401"); static const uint8_t REG_PRESSURE = 0x06; static const uint8_t REG_TEMPERATURE = 0x09; @@ -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/__init__.py b/esphome/components/xgzp68xx/__init__.py index 122ffaf6ed..6cebeffba1 100644 --- a/esphome/components/xgzp68xx/__init__.py +++ b/esphome/components/xgzp68xx/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@gcormier"] +DOMAIN = "xgzp68xx" 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/xgzp68xx/xgzp68xx.cpp b/esphome/components/xgzp68xx/xgzp68xx.cpp index 0eefd594d0..795db448f0 100644 --- a/esphome/components/xgzp68xx/xgzp68xx.cpp +++ b/esphome/components/xgzp68xx/xgzp68xx.cpp @@ -8,7 +8,9 @@ namespace esphome::xgzp68xx { -static const char *const TAG = "xgzp68xx.sensor"; +ESPHOME_LOG_TAG(TAG, "xgzp68xx.sensor"); + +static constexpr uint32_t MEASUREMENT_TIMEOUT_ID = 0; static const uint8_t CMD_ADDRESS = 0x30; static const uint8_t SYSCONFIG_ADDRESS = 0xA5; @@ -54,7 +56,7 @@ void XGZP68XXComponent::update() { this->write_register(0x30, &READ_COMMAND, 1); // Wait 20mS per datasheet - this->set_timeout("measurement", 20, [this]() { + this->set_timeout(MEASUREMENT_TIMEOUT_ID, 20, [this]() { uint8_t data[5] = {}; uint32_t pressure_raw = 0; uint16_t temperature_raw = 0; diff --git a/esphome/components/xiaomi_ble/__init__.py b/esphome/components/xiaomi_ble/__init__.py index 16da83934e..de7998b290 100644 --- a/esphome/components/xiaomi_ble/__init__.py +++ b/esphome/components/xiaomi_ble/__init__.py @@ -5,6 +5,7 @@ from esphome.const import CONF_ID from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base"] +DOMAIN = "xiaomi_ble" xiaomi_ble_ns = cg.esphome_ns.namespace("xiaomi_ble") XiaomiListener = xiaomi_ble_ns.class_( diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 06c3a7ab7a..57f26b6acc 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -21,7 +21,7 @@ namespace esphome::xiaomi_ble { -static const char *const TAG = "xiaomi_ble"; +ESPHOME_LOG_TAG(TAG, "xiaomi_ble"); // Maximum bytes to log in very verbose hex output (covers largest packet of ~24 bytes) static constexpr size_t XIAOMI_MAX_LOG_BYTES = 32; diff --git a/esphome/components/xiaomi_body_scale/__init__.py b/esphome/components/xiaomi_body_scale/__init__.py new file mode 100644 index 0000000000..aeb29d234f --- /dev/null +++ b/esphome/components/xiaomi_body_scale/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@dckiller51"] +DOMAIN = "xiaomi_body_scale" 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..b59c662e80 --- /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 { + +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/xiaomi_cgd1/__init__.py index e69de29bb2..f76e529bb2 100644 --- a/esphome/components/xiaomi_cgd1/__init__.py +++ b/esphome/components/xiaomi_cgd1/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_cgd1" 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_cgd1/xiaomi_cgd1.cpp b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp index 0159314f4d..d64e66e660 100644 --- a/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp +++ b/esphome/components/xiaomi_cgd1/xiaomi_cgd1.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_cgd1 { -static const char *const TAG = "xiaomi_cgd1"; +ESPHOME_LOG_TAG(TAG, "xiaomi_cgd1"); static constexpr size_t CGD1_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_cgdk2/__init__.py b/esphome/components/xiaomi_cgdk2/__init__.py index e69de29bb2..5a9b433236 100644 --- a/esphome/components/xiaomi_cgdk2/__init__.py +++ b/esphome/components/xiaomi_cgdk2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_cgdk2" 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_cgdk2/xiaomi_cgdk2.cpp b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp index 01912c3778..7ac6d50e16 100644 --- a/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp +++ b/esphome/components/xiaomi_cgdk2/xiaomi_cgdk2.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_cgdk2 { -static const char *const TAG = "xiaomi_cgdk2"; +ESPHOME_LOG_TAG(TAG, "xiaomi_cgdk2"); static constexpr size_t CGDK2_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_cgg1/__init__.py b/esphome/components/xiaomi_cgg1/__init__.py index e69de29bb2..ea01df8c57 100644 --- a/esphome/components/xiaomi_cgg1/__init__.py +++ b/esphome/components/xiaomi_cgg1/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_cgg1" 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_cgg1/xiaomi_cgg1.cpp b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp index 679cb76198..64207868b7 100644 --- a/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp +++ b/esphome/components/xiaomi_cgg1/xiaomi_cgg1.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_cgg1 { -static const char *const TAG = "xiaomi_cgg1"; +ESPHOME_LOG_TAG(TAG, "xiaomi_cgg1"); static constexpr size_t CGG1_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_cgpr1/__init__.py b/esphome/components/xiaomi_cgpr1/__init__.py index e69de29bb2..b17553724d 100644 --- a/esphome/components/xiaomi_cgpr1/__init__.py +++ b/esphome/components/xiaomi_cgpr1/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_cgpr1" 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_cgpr1/xiaomi_cgpr1.cpp b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp index 019de54ad8..1515e43dea 100644 --- a/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp +++ b/esphome/components/xiaomi_cgpr1/xiaomi_cgpr1.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_cgpr1 { -static const char *const TAG = "xiaomi_cgpr1"; +ESPHOME_LOG_TAG(TAG, "xiaomi_cgpr1"); void XiaomiCGPR1::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi CGPR1"); diff --git a/esphome/components/xiaomi_gcls002/__init__.py b/esphome/components/xiaomi_gcls002/__init__.py index e69de29bb2..643a26ffe2 100644 --- a/esphome/components/xiaomi_gcls002/__init__.py +++ b/esphome/components/xiaomi_gcls002/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_gcls002" 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_gcls002/xiaomi_gcls002.cpp b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp index 27effd64bb..11cb3a98d3 100644 --- a/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp +++ b/esphome/components/xiaomi_gcls002/xiaomi_gcls002.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_gcls002 { -static const char *const TAG = "xiaomi_gcls002"; +ESPHOME_LOG_TAG(TAG, "xiaomi_gcls002"); void XiaomiGCLS002::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi GCLS002"); diff --git a/esphome/components/xiaomi_hhccjcy01/__init__.py b/esphome/components/xiaomi_hhccjcy01/__init__.py index e69de29bb2..e8e38dc51f 100644 --- a/esphome/components/xiaomi_hhccjcy01/__init__.py +++ b/esphome/components/xiaomi_hhccjcy01/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_hhccjcy01" 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_hhccjcy01/xiaomi_hhccjcy01.cpp b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp index 5e2369a6d9..80de9a6cbc 100644 --- a/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp +++ b/esphome/components/xiaomi_hhccjcy01/xiaomi_hhccjcy01.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_hhccjcy01 { -static const char *const TAG = "xiaomi_hhccjcy01"; +ESPHOME_LOG_TAG(TAG, "xiaomi_hhccjcy01"); void XiaomiHHCCJCY01::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi HHCCJCY01"); diff --git a/esphome/components/xiaomi_hhccjcy10/__init__.py b/esphome/components/xiaomi_hhccjcy10/__init__.py index d47cef13c6..eeb0e7d7ef 100644 --- a/esphome/components/xiaomi_hhccjcy10/__init__.py +++ b/esphome/components/xiaomi_hhccjcy10/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@fariouche"] +DOMAIN = "xiaomi_hhccjcy10" 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_hhccjcy10/xiaomi_hhccjcy10.cpp b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp index 680eb04e77..ac1541c386 100644 --- a/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp +++ b/esphome/components/xiaomi_hhccjcy10/xiaomi_hhccjcy10.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_hhccjcy10 { -static const char *const TAG = "xiaomi_hhccjcy10"; +ESPHOME_LOG_TAG(TAG, "xiaomi_hhccjcy10"); void XiaomiHHCCJCY10::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi HHCCJCY10"); diff --git a/esphome/components/xiaomi_hhccpot002/__init__.py b/esphome/components/xiaomi_hhccpot002/__init__.py index e69de29bb2..4d74c2f432 100644 --- a/esphome/components/xiaomi_hhccpot002/__init__.py +++ b/esphome/components/xiaomi_hhccpot002/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_hhccpot002" 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_hhccpot002/xiaomi_hhccpot002.cpp b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp index fc8d15228d..f502cb1f66 100644 --- a/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp +++ b/esphome/components/xiaomi_hhccpot002/xiaomi_hhccpot002.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_hhccpot002 { -static const char *const TAG = "xiaomi_hhccpot002"; +ESPHOME_LOG_TAG(TAG, "xiaomi_hhccpot002"); void XiaomiHHCCPOT002 ::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi HHCCPOT002"); diff --git a/esphome/components/xiaomi_jqjcy01ym/__init__.py b/esphome/components/xiaomi_jqjcy01ym/__init__.py index e69de29bb2..e381fe6172 100644 --- a/esphome/components/xiaomi_jqjcy01ym/__init__.py +++ b/esphome/components/xiaomi_jqjcy01ym/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_jqjcy01ym" 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_jqjcy01ym/xiaomi_jqjcy01ym.cpp b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp index f7a1318d7c..ad2383f107 100644 --- a/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp +++ b/esphome/components/xiaomi_jqjcy01ym/xiaomi_jqjcy01ym.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_jqjcy01ym { -static const char *const TAG = "xiaomi_jqjcy01ym"; +ESPHOME_LOG_TAG(TAG, "xiaomi_jqjcy01ym"); void XiaomiJQJCY01YM::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi JQJCY01YM"); diff --git a/esphome/components/xiaomi_lywsd02/__init__.py b/esphome/components/xiaomi_lywsd02/__init__.py index e69de29bb2..e8eb493338 100644 --- a/esphome/components/xiaomi_lywsd02/__init__.py +++ b/esphome/components/xiaomi_lywsd02/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_lywsd02" 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_lywsd02/xiaomi_lywsd02.cpp b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp index d465f2fec0..0762d97eb0 100644 --- a/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp +++ b/esphome/components/xiaomi_lywsd02/xiaomi_lywsd02.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_lywsd02 { -static const char *const TAG = "xiaomi_lywsd02"; +ESPHOME_LOG_TAG(TAG, "xiaomi_lywsd02"); void XiaomiLYWSD02::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi LYWSD02"); diff --git a/esphome/components/xiaomi_lywsd02mmc/__init__.py b/esphome/components/xiaomi_lywsd02mmc/__init__.py index e69de29bb2..2398660e95 100644 --- a/esphome/components/xiaomi_lywsd02mmc/__init__.py +++ b/esphome/components/xiaomi_lywsd02mmc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_lywsd02mmc" 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_lywsd02mmc/xiaomi_lywsd02mmc.cpp b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp index dca5f73909..15d9f32bd5 100644 --- a/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp +++ b/esphome/components/xiaomi_lywsd02mmc/xiaomi_lywsd02mmc.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_lywsd02mmc { -static const char *const TAG = "xiaomi_lywsd02mmc"; +ESPHOME_LOG_TAG(TAG, "xiaomi_lywsd02mmc"); static constexpr size_t LYWSD02MMC_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_lywsd03mmc/__init__.py b/esphome/components/xiaomi_lywsd03mmc/__init__.py index e69de29bb2..3d1638fc11 100644 --- a/esphome/components/xiaomi_lywsd03mmc/__init__.py +++ b/esphome/components/xiaomi_lywsd03mmc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_lywsd03mmc" 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_lywsd03mmc/xiaomi_lywsd03mmc.cpp b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp index 356a4ffd4e..3bf8b7328b 100644 --- a/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp +++ b/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_lywsd03mmc { -static const char *const TAG = "xiaomi_lywsd03mmc"; +ESPHOME_LOG_TAG(TAG, "xiaomi_lywsd03mmc"); static constexpr size_t LYWSD03MMC_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_lywsdcgq/__init__.py b/esphome/components/xiaomi_lywsdcgq/__init__.py index e69de29bb2..bbf235aa96 100644 --- a/esphome/components/xiaomi_lywsdcgq/__init__.py +++ b/esphome/components/xiaomi_lywsdcgq/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_lywsdcgq" 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_lywsdcgq/xiaomi_lywsdcgq.cpp b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp index 1ddf7ec235..1c56ed34f6 100644 --- a/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp +++ b/esphome/components/xiaomi_lywsdcgq/xiaomi_lywsdcgq.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_lywsdcgq { -static const char *const TAG = "xiaomi_lywsdcgq"; +ESPHOME_LOG_TAG(TAG, "xiaomi_lywsdcgq"); void XiaomiLYWSDCGQ::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi LYWSDCGQ"); diff --git a/esphome/components/xiaomi_mccgq02hl/__init__.py b/esphome/components/xiaomi_mccgq02hl/__init__.py new file mode 100644 index 0000000000..a65bad1cb1 --- /dev/null +++ b/esphome/components/xiaomi_mccgq02hl/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@ahpohl", "@morph027"] +DOMAIN = "xiaomi_mccgq02hl" 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..519358270d --- /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 { + +ESPHOME_LOG_TAG(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/__init__.py b/esphome/components/xiaomi_mhoc303/__init__.py index e7005b3957..5ffa02332f 100644 --- a/esphome/components/xiaomi_mhoc303/__init__.py +++ b/esphome/components/xiaomi_mhoc303/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@drug123"] +DOMAIN = "xiaomi_mhoc303" 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_mhoc303/xiaomi_mhoc303.cpp b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp index 9706e50861..23f20f380a 100644 --- a/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp +++ b/esphome/components/xiaomi_mhoc303/xiaomi_mhoc303.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_mhoc303 { -static const char *const TAG = "xiaomi_mhoc303"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mhoc303"); void XiaomiMHOC303::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi MHOC303"); diff --git a/esphome/components/xiaomi_mhoc401/__init__.py b/esphome/components/xiaomi_mhoc401/__init__.py index e69de29bb2..14fe18af57 100644 --- a/esphome/components/xiaomi_mhoc401/__init__.py +++ b/esphome/components/xiaomi_mhoc401/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_mhoc401" 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_mhoc401/xiaomi_mhoc401.cpp b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp index d725978418..97266c86e7 100644 --- a/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp +++ b/esphome/components/xiaomi_mhoc401/xiaomi_mhoc401.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_mhoc401 { -static const char *const TAG = "xiaomi_mhoc401"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mhoc401"); static constexpr size_t MHOC401_BINDKEY_SIZE = 16; diff --git a/esphome/components/xiaomi_miscale/__init__.py b/esphome/components/xiaomi_miscale/__init__.py index e69de29bb2..ddda4a0a5b 100644 --- a/esphome/components/xiaomi_miscale/__init__.py +++ b/esphome/components/xiaomi_miscale/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_miscale" 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_miscale/xiaomi_miscale.cpp b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp index 482c0ed395..438b9b5529 100644 --- a/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp +++ b/esphome/components/xiaomi_miscale/xiaomi_miscale.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_miscale { -static const char *const TAG = "xiaomi_miscale"; +ESPHOME_LOG_TAG(TAG, "xiaomi_miscale"); void XiaomiMiscale::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi Miscale"); diff --git a/esphome/components/xiaomi_miscale2/__init__.py b/esphome/components/xiaomi_miscale2/__init__.py index e69de29bb2..346db02445 100644 --- a/esphome/components/xiaomi_miscale2/__init__.py +++ b/esphome/components/xiaomi_miscale2/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_miscale2" diff --git a/esphome/components/xiaomi_mjyd02yla/__init__.py b/esphome/components/xiaomi_mjyd02yla/__init__.py index e69de29bb2..eb881ca39a 100644 --- a/esphome/components/xiaomi_mjyd02yla/__init__.py +++ b/esphome/components/xiaomi_mjyd02yla/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_mjyd02yla" 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_mjyd02yla/xiaomi_mjyd02yla.cpp b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp index 233f5f5783..767bcd8da0 100644 --- a/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp +++ b/esphome/components/xiaomi_mjyd02yla/xiaomi_mjyd02yla.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_mjyd02yla { -static const char *const TAG = "xiaomi_mjyd02yla"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mjyd02yla"); void XiaomiMJYD02YLA::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi MJYD02YL-A"); diff --git a/esphome/components/xiaomi_mue4094rt/__init__.py b/esphome/components/xiaomi_mue4094rt/__init__.py index e69de29bb2..3d539b267d 100644 --- a/esphome/components/xiaomi_mue4094rt/__init__.py +++ b/esphome/components/xiaomi_mue4094rt/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_mue4094rt" diff --git a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp index eca83c0912..683e8a2a92 100644 --- a/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp +++ b/esphome/components/xiaomi_mue4094rt/xiaomi_mue4094rt.cpp @@ -3,7 +3,9 @@ namespace esphome::xiaomi_mue4094rt { -static const char *const TAG = "xiaomi_mue4094rt"; +ESPHOME_LOG_TAG(TAG, "xiaomi_mue4094rt"); + +static constexpr uint32_t MOTION_TIMEOUT_ID = 0; void XiaomiMUE4094RT::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi MUE4094RT"); @@ -40,7 +42,7 @@ bool XiaomiMUE4094RT::parse_device(const ble_device_base::ESPBTDevice &device) { } if (res->has_motion.has_value()) { this->publish_state(*res->has_motion); - this->set_timeout("motion_timeout", timeout_, [this]() { this->publish_state(false); }); + this->set_timeout(MOTION_TIMEOUT_ID, timeout_, [this]() { this->publish_state(false); }); } success = true; } diff --git a/esphome/components/xiaomi_rtcgq02lm/__init__.py b/esphome/components/xiaomi_rtcgq02lm/__init__.py index 7b289a8ee3..71d7e0a265 100644 --- a/esphome/components/xiaomi_rtcgq02lm/__init__.py +++ b/esphome/components/xiaomi_rtcgq02lm/__init__.py @@ -6,6 +6,7 @@ from esphome.types import ConfigType AUTO_LOAD = ["ble_device_base", "xiaomi_ble"] CODEOWNERS = ["@jesserockz"] +DOMAIN = "xiaomi_rtcgq02lm" MULTI_CONF = True xiaomi_rtcgq02lm_ns = cg.esphome_ns.namespace("xiaomi_rtcgq02lm") 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_rtcgq02lm/xiaomi_rtcgq02lm.cpp b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp index f349dfa797..f7ecf15c3b 100644 --- a/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp +++ b/esphome/components/xiaomi_rtcgq02lm/xiaomi_rtcgq02lm.cpp @@ -4,7 +4,10 @@ namespace esphome::xiaomi_rtcgq02lm { -static const char *const TAG = "xiaomi_rtcgq02lm"; +ESPHOME_LOG_TAG(TAG, "xiaomi_rtcgq02lm"); + +static constexpr uint32_t MOTION_TIMEOUT_ID = 0; +static constexpr uint32_t BUTTON_TIMEOUT_ID = 1; static constexpr size_t RTCGQ02LM_BINDKEY_SIZE = 16; @@ -55,13 +58,13 @@ bool XiaomiRTCGQ02LM::parse_device(const ble_device_base::ESPBTDevice &device) { #ifdef USE_BINARY_SENSOR if (res->has_motion.has_value() && this->motion_ != nullptr) { this->motion_->publish_state(*res->has_motion); - this->set_timeout("motion_timeout", this->motion_timeout_, [this]() { this->motion_->publish_state(false); }); + this->set_timeout(MOTION_TIMEOUT_ID, this->motion_timeout_, [this]() { this->motion_->publish_state(false); }); } if (res->is_light.has_value() && this->light_ != nullptr) this->light_->publish_state(*res->is_light); if (res->button_press.has_value() && this->button_ != nullptr) { this->button_->publish_state(*res->button_press); - this->set_timeout("button_timeout", this->button_timeout_, [this]() { this->button_->publish_state(false); }); + this->set_timeout(BUTTON_TIMEOUT_ID, this->button_timeout_, [this]() { this->button_->publish_state(false); }); } #endif #ifdef USE_SENSOR diff --git a/esphome/components/xiaomi_wx08zm/__init__.py b/esphome/components/xiaomi_wx08zm/__init__.py index e69de29bb2..d795931df9 100644 --- a/esphome/components/xiaomi_wx08zm/__init__.py +++ b/esphome/components/xiaomi_wx08zm/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_wx08zm" 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_wx08zm/xiaomi_wx08zm.cpp b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp index ae37d63096..420b67e788 100644 --- a/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp +++ b/esphome/components/xiaomi_wx08zm/xiaomi_wx08zm.cpp @@ -3,7 +3,7 @@ namespace esphome::xiaomi_wx08zm { -static const char *const TAG = "xiaomi_wx08zm"; +ESPHOME_LOG_TAG(TAG, "xiaomi_wx08zm"); void XiaomiWX08ZM::dump_config() { ESP_LOGCONFIG(TAG, "Xiaomi WX08ZM"); diff --git a/esphome/components/xiaomi_xmwsdj04mmc/__init__.py b/esphome/components/xiaomi_xmwsdj04mmc/__init__.py index e69de29bb2..98af32069c 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/__init__.py +++ b/esphome/components/xiaomi_xmwsdj04mmc/__init__.py @@ -0,0 +1 @@ +DOMAIN = "xiaomi_xmwsdj04mmc" 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/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp index aba954fd91..65ab9e342d 100644 --- a/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp +++ b/esphome/components/xiaomi_xmwsdj04mmc/xiaomi_xmwsdj04mmc.cpp @@ -4,7 +4,7 @@ namespace esphome::xiaomi_xmwsdj04mmc { -static const char *const TAG = "xiaomi_xmwsdj04mmc"; +ESPHOME_LOG_TAG(TAG, "xiaomi_xmwsdj04mmc"); static constexpr size_t XMWSDJ04MMC_BINDKEY_SIZE = 16; diff --git a/esphome/components/xl9535/__init__.py b/esphome/components/xl9535/__init__.py index 5686b74173..1c6548bb5f 100644 --- a/esphome/components/xl9535/__init__.py +++ b/esphome/components/xl9535/__init__.py @@ -17,6 +17,7 @@ CONF_XL9535 = "xl9535" DEPENDENCIES = ["i2c"] CODEOWNERS = ["@mreditor97"] +DOMAIN = "xl9535" xl9535_ns = cg.esphome_ns.namespace(CONF_XL9535) @@ -54,7 +55,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/xl9535/xl9535.cpp b/esphome/components/xl9535/xl9535.cpp index d189d9b5c7..228274b321 100644 --- a/esphome/components/xl9535/xl9535.cpp +++ b/esphome/components/xl9535/xl9535.cpp @@ -3,7 +3,7 @@ namespace esphome::xl9535 { -static const char *const TAG = "xl9535"; +ESPHOME_LOG_TAG(TAG, "xl9535"); void XL9535Component::setup() { // Check to see if the device can read from the register diff --git a/esphome/components/xpt2046/__init__.py b/esphome/components/xpt2046/__init__.py index 3b8a925bb2..a2b1372b62 100644 --- a/esphome/components/xpt2046/__init__.py +++ b/esphome/components/xpt2046/__init__.py @@ -1,5 +1,7 @@ import esphome.config_validation as cv +DOMAIN = "xpt2046" + CONFIG_SCHEMA = cv.invalid( "This component sould now be used as platform of the Touchscreen component." ) diff --git a/esphome/components/xpt2046/touchscreen/xpt2046.cpp b/esphome/components/xpt2046/touchscreen/xpt2046.cpp index 83a7332005..afb8f6cd5c 100644 --- a/esphome/components/xpt2046/touchscreen/xpt2046.cpp +++ b/esphome/components/xpt2046/touchscreen/xpt2046.cpp @@ -13,7 +13,7 @@ static constexpr uint8_t XPT_READ_Y = 0x90; static constexpr uint8_t XPT_ADC_ON = 0x01; static constexpr uint8_t XPT_VREF_ON = 0x02; -static const char *const TAG = "xpt2046"; +ESPHOME_LOG_TAG(TAG, "xpt2046"); void XPT2046Component::setup() { if (this->irq_pin_ != nullptr) { diff --git a/esphome/components/xxtea/__init__.py b/esphome/components/xxtea/__init__.py index 7a7bce1781..79d31dc44a 100644 --- a/esphome/components/xxtea/__init__.py +++ b/esphome/components/xxtea/__init__.py @@ -1,3 +1,4 @@ """ESPHome XXTEA encryption component.""" CODEOWNERS = ["@clydebarrow"] +DOMAIN = "xxtea" diff --git a/esphome/components/yashima/__init__.py b/esphome/components/yashima/__init__.py index e69de29bb2..587acabe78 100644 --- a/esphome/components/yashima/__init__.py +++ b/esphome/components/yashima/__init__.py @@ -0,0 +1 @@ +DOMAIN = "yashima" diff --git a/esphome/components/yashima/yashima.cpp b/esphome/components/yashima/yashima.cpp index ba0a3a6404..986237b2d6 100644 --- a/esphome/components/yashima/yashima.cpp +++ b/esphome/components/yashima/yashima.cpp @@ -3,7 +3,7 @@ namespace esphome::yashima { -static const char *const TAG = "yashima.climate"; +ESPHOME_LOG_TAG(TAG, "yashima.climate"); const uint16_t YASHIMA_STATE_LENGTH = 9; const uint16_t YASHIMA_BITS = YASHIMA_STATE_LENGTH * 8; diff --git a/esphome/components/zephyr/__init__.py b/esphome/components/zephyr/__init__.py index 2bcdf0380c..4f69f00339 100644 --- a/esphome/components/zephyr/__init__.py +++ b/esphome/components/zephyr/__init__.py @@ -26,6 +26,7 @@ from .const import ( ) CODEOWNERS = ["@tomaszduda23"] +DOMAIN = "zephyr" class HexValue: @@ -210,6 +211,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/gpio.cpp b/esphome/components/zephyr/gpio.cpp index 23da2cafac..57a31e302e 100644 --- a/esphome/components/zephyr/gpio.cpp +++ b/esphome/components/zephyr/gpio.cpp @@ -1,5 +1,6 @@ #ifdef USE_ZEPHYR #include "gpio.h" +#include "esphome/core/hal.h" #include #include #include "esphome/core/log.h" @@ -7,7 +8,7 @@ namespace esphome { namespace zephyr { -static const char *const TAG = "zephyr"; +ESPHOME_LOG_TAG(TAG, "zephyr"); static gpio_flags_t flags_to_mode(gpio::Flags flags, bool inverted, bool value) { gpio_flags_t ret = 0; @@ -79,7 +80,7 @@ ISRInternalGPIOPin ZephyrGPIOPin::to_isr() const { return ISRInternalGPIOPin((void *) arg); } -void ZephyrGPIOPin::attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const { +void ZephyrGPIOPin::attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const { if (!device_is_ready(this->gpio_)) { ESP_LOGE(TAG, "Cannot attach interrupt: GPIO device not ready"); return; diff --git a/esphome/components/zephyr/gpio.h b/esphome/components/zephyr/gpio.h index 71d1620a67..dd26e34eb8 100644 --- a/esphome/components/zephyr/gpio.h +++ b/esphome/components/zephyr/gpio.h @@ -1,7 +1,7 @@ #pragma once #ifdef USE_ZEPHYR -#include "esphome/core/hal.h" +#include "esphome/core/gpio_pin.h" #include #include namespace esphome::zephyr { @@ -16,7 +16,7 @@ struct ZephyrGPIOInterrupt { void *arg{nullptr}; }; -class ZephyrGPIOPin final : public InternalGPIOPin { +class ZephyrGPIOPin final : public GPIOPin { public: ZephyrGPIOPin(const device *gpio, int gpio_size, const char *pin_name_prefix) { this->gpio_ = gpio; @@ -32,14 +32,18 @@ class ZephyrGPIOPin final : public InternalGPIOPin { bool digital_read() override; void digital_write(bool value) override; size_t dump_summary(char *buffer, size_t len) const override; - void detach_interrupt() const override; - ISRInternalGPIOPin to_isr() const override; - uint8_t get_pin() const override { return this->pin_; } - bool is_inverted() const override { return this->inverted_; } + void detach_interrupt() const; + template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { + this->attach_interrupt_(reinterpret_cast(func), arg, type); + } + ISRInternalGPIOPin to_isr() const; + uint8_t get_pin() const { return this->pin_; } + bool is_inverted() const { return this->inverted_; } + bool is_internal() override { return true; } gpio::Flags get_flags() const override { return flags_; } protected: - void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const override; + void attach_interrupt_(void (*func)(void *), void *arg, gpio::InterruptType type) const; const device *gpio_{nullptr}; const char *pin_name_prefix_{nullptr}; gpio::Flags flags_{}; @@ -48,8 +52,7 @@ class ZephyrGPIOPin final : public InternalGPIOPin { bool inverted_{}; bool value_{false}; - // attach_interrupt()/detach_interrupt() are const (matching the base class), so - // the interrupt state they manage has to be mutable. + // attach_interrupt_()/detach_interrupt() are const, so their interrupt state is mutable. mutable ZephyrGPIOInterrupt interrupt_{}; }; diff --git a/esphome/components/zephyr/hal.cpp b/esphome/components/zephyr/hal.cpp index ad8ed5c95c..96c7df896c 100644 --- a/esphome/components/zephyr/hal.cpp +++ b/esphome/components/zephyr/hal.cpp @@ -23,11 +23,16 @@ static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0)); // components/zephyr/hal.h. void arch_init() { + main_loop_thread() = k_current_get(); #ifdef CONFIG_WATCHDOG if (device_is_ready(WDT)) { static wdt_timeout_cfg wdt_config{}; wdt_config.flags = WDT_FLAG_RESET_SOC; -#ifdef USE_ZIGBEE +#ifndef USE_BOOTLOADER_MCUBOOT + // Adafruit bootloader doesn't feed the WDT while + // erasing flash during a firmware update, so a shorter timeout can break the update. + wdt_config.window.max = 30000; +#elif defined(USE_ZIGBEE) // zboss thread uses a lot of CPU cycles during startup wdt_config.window.max = 10000; #else diff --git a/esphome/components/zephyr/hal.h b/esphome/components/zephyr/hal.h index 11994b68b7..8384225d33 100644 --- a/esphome/components/zephyr/hal.h +++ b/esphome/components/zephyr/hal.h @@ -17,6 +17,14 @@ namespace esphome { /// Zephyr/nRF52: not currently consulted — wake path is platform-specific. __attribute__((always_inline)) inline bool in_isr_context() { return false; } +inline k_tid_t &main_loop_thread() { + static k_tid_t thread = nullptr; + return thread; +} + +/// arch_init() stores the thread before any component runs, so no unset case exists. +__attribute__((always_inline)) inline bool is_main_loop_thread() { return k_current_get() == main_loop_thread(); } + __attribute__((always_inline)) inline void yield() { ::k_yield(); } __attribute__((always_inline)) inline void delay(uint32_t ms) { ::k_msleep(ms); } __attribute__((always_inline)) inline uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); } diff --git a/esphome/components/zephyr/preferences.cpp b/esphome/components/zephyr/preferences.cpp index ed22613625..6be5f1ce4a 100644 --- a/esphome/components/zephyr/preferences.cpp +++ b/esphome/components/zephyr/preferences.cpp @@ -10,7 +10,7 @@ namespace esphome::zephyr { -static const char *const TAG = "preferences"; +ESPHOME_LOG_TAG(TAG, "preferences"); bool ZephyrPreferenceBackend::save(const uint8_t *data, size_t len) { this->data.resize(len); diff --git a/esphome/components/zephyr/reset_reason.cpp b/esphome/components/zephyr/reset_reason.cpp index 24b32196db..f3f126537d 100644 --- a/esphome/components/zephyr/reset_reason.cpp +++ b/esphome/components/zephyr/reset_reason.cpp @@ -7,7 +7,7 @@ namespace esphome::zephyr { -static const char *const TAG = "zephyr"; +ESPHOME_LOG_TAG(TAG, "zephyr"); static size_t append_reset_reason(char *buf, size_t size, size_t pos, bool set, const char *reason) { if (!set) { 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.cpp b/esphome/components/zephyr_ble_server/ble_server.cpp index 15993abcce..c890fba3d7 100644 --- a/esphome/components/zephyr_ble_server/ble_server.cpp +++ b/esphome/components/zephyr_ble_server/ble_server.cpp @@ -1,25 +1,22 @@ #ifdef USE_ZEPHYR #include "ble_server.h" +#include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include #include namespace esphome::zephyr_ble_server { -static const char *const TAG = "zephyr_ble_server"; +ESPHOME_LOG_TAG(TAG, "zephyr_ble_server"); static k_work advertise_work; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) BLEServer *global_ble_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -#define DEVICE_NAME CONFIG_BT_DEVICE_NAME -#define DEVICE_NAME_LEN (sizeof(DEVICE_NAME) - 1) - -static const bt_data AD[] = { - BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), - BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, DEVICE_NAME_LEN), -}; +// What the advertisement has left for the name: the flags take 3 bytes and the name header 2 +static constexpr size_t MAX_ADV_NAME_LEN = BT_GAP_ADV_MAX_ADV_DATA_LEN - 3 - 2; static const bt_data SD[] = { #ifdef USE_OTA @@ -36,7 +33,19 @@ static void advertise(k_work *work) { ESP_LOGE(TAG, "Advertising failed to stop (rc %d)", rc); } - rc = bt_le_adv_start(ADV_PARAM, AD, ARRAY_SIZE(AD), SD, ARRAY_SIZE(SD)); + // Zephyr would cut the end of a long name, which is where the MAC suffix is + char name[MAX_ADV_NAME_LEN + 1]; + const auto &app_name = App.get_name(); + const size_t name_len = + truncate_name_to(name, sizeof(name), app_name.c_str(), app_name.length(), + App.is_name_add_mac_suffix_enabled() ? MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN : 0); + const uint8_t name_type = name_len < app_name.length() ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE; + const bt_data ad[] = { + BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), + BT_DATA(name_type, name, static_cast(name_len)), + }; + + rc = bt_le_adv_start(ADV_PARAM, ad, ARRAY_SIZE(ad), SD, ARRAY_SIZE(SD)); if (rc) { ESP_LOGE(TAG, "Advertising failed to start (rc %d)", rc); return; 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/__init__.py b/esphome/components/zephyr_mcumgr/__init__.py index e69de29bb2..945cc5807d 100644 --- a/esphome/components/zephyr_mcumgr/__init__.py +++ b/esphome/components/zephyr_mcumgr/__init__.py @@ -0,0 +1 @@ +DOMAIN = "zephyr_mcumgr" diff --git a/esphome/components/zephyr_mcumgr/ota/__init__.py b/esphome/components/zephyr_mcumgr/ota/__init__.py index ad89c1ac79..ed7069a540 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, @@ -11,12 +13,18 @@ from esphome.components.zephyr import ( from esphome.components.zephyr.const import ( BOOTLOADER_MCUBOOT, KEY_BOOTLOADER, + KEY_PM_STATIC, 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 +73,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 +128,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) @@ -131,16 +147,19 @@ async def to_code(config: ConfigType) -> None: zephyr_add_prj_conf("MCUMGR_MGMT_NOTIFICATION_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_STATUS_HOOKS", True) zephyr_add_prj_conf("MCUMGR_GRP_IMG_UPLOAD_CHECK_HOOK", True) + # OS management group (reset, MCUMGR_PARAMETERS query, etc.) isn't transport-specific -- + # every mcumgr transport needs it, not just BLE. + zephyr_add_prj_conf("MCUMGR_GRP_OS", True) + zephyr_add_prj_conf("MCUMGR_GRP_OS_MCUMGR_PARAMS", True) transport = config[CONF_TRANSPORT] if transport[CONF_BLE]: zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT", True) zephyr_add_prj_conf("MCUMGR_TRANSPORT_BT_REASSEMBLY", True) - zephyr_add_prj_conf("MCUMGR_GRP_OS", True) - 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,16 +177,43 @@ 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 bootloader = zephyr_data()[KEY_BOOTLOADER] if bootloader != BOOTLOADER_MCUBOOT: - sections = BOOTLOADER_CONFIG[bootloader] - # Derive partition addresses from the SoftDevice and bootloader sections so - # that the DTS flash map matches what the Partition Manager produces: + # Derive partition addresses from the SoftDevice and bootloader sections + # registered with the Partition Manager (moved in testing mode) so that + # the DTS flash map matches what it produces: # MCUboot sits immediately after the SoftDevice, then slot0, then slot1. + sections = zephyr_data()[KEY_PM_STATIC] mcuboot_size = 0x9000 sd_end = next(s.address + s.size for s in sections if "SoftDevice" in s.name) bl_start = next(s.address for s in sections if "Adafruit" in s.name) diff --git a/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp b/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp index f1eac462bc..af126a29c7 100644 --- a/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp +++ b/esphome/components/zephyr_mcumgr/ota/ota_zephyr_mcumgr.cpp @@ -27,7 +27,7 @@ static_assert(sizeof(struct img_mgmt_upload_req) == 8, "ABI mismatch"); static_assert(offsetof(struct img_mgmt_upload_req, image) == 0, "ABI mismatch"); static_assert(offsetof(struct img_mgmt_upload_req, off) == 4, "ABI mismatch"); -static const char *const TAG = "zephyr_mcumgr"; +ESPHOME_LOG_TAG(TAG, "zephyr_mcumgr"); static OTAComponent *global_ota_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static enum mgmt_cb_return mcumgr_img_mgmt_cb(uint32_t event, enum mgmt_cb_return prev_status, int32_t *rc, diff --git a/esphome/components/zephyr_pwm/__init__.py b/esphome/components/zephyr_pwm/__init__.py index 4bcce84845..4d3382d617 100644 --- a/esphome/components/zephyr_pwm/__init__.py +++ b/esphome/components/zephyr_pwm/__init__.py @@ -1 +1,2 @@ CODEOWNERS = ["@wiomoc"] +DOMAIN = "zephyr_pwm" diff --git a/esphome/components/zephyr_pwm/output.py b/esphome/components/zephyr_pwm/output.py index 2cb62b85b5..604cffb0e0 100644 --- a/esphome/components/zephyr_pwm/output.py +++ b/esphome/components/zephyr_pwm/output.py @@ -4,6 +4,7 @@ from typing import Any from esphome import pins import esphome.codegen as cg from esphome.components import output +from esphome.components.output import DOMAIN as OUTPUT_DOMAIN from esphome.components.zephyr import zephyr_add_overlay_builder, zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( @@ -12,7 +13,6 @@ from esphome.const import ( CONF_ID, CONF_INVERTED, CONF_NUMBER, - CONF_OUTPUT, CONF_PIN, CONF_PLATFORM, ) @@ -74,7 +74,7 @@ def _allocate_blocks() -> None: full_config = fv.full_config.get() zephyr_pwm_conf = [ cfg - for cfg in full_config.get(CONF_OUTPUT, []) + for cfg in full_config.get(OUTPUT_DOMAIN, []) if cfg.get(CONF_PLATFORM) == DOMAIN ] diff --git a/esphome/components/zephyr_pwm/zephyr_pwm.cpp b/esphome/components/zephyr_pwm/zephyr_pwm.cpp index aa1393388a..71d54e8e48 100644 --- a/esphome/components/zephyr_pwm/zephyr_pwm.cpp +++ b/esphome/components/zephyr_pwm/zephyr_pwm.cpp @@ -6,7 +6,7 @@ namespace esphome::zephyr_pwm { -static const char *const TAG = "zephyr_pwm"; +ESPHOME_LOG_TAG(TAG, "zephyr_pwm"); void ZephyrPWMChannel::setup() { if (!device_is_ready(this->device_)) { diff --git a/esphome/components/zhlt01/__init__.py b/esphome/components/zhlt01/__init__.py index e69de29bb2..c17009d722 100644 --- a/esphome/components/zhlt01/__init__.py +++ b/esphome/components/zhlt01/__init__.py @@ -0,0 +1 @@ +DOMAIN = "zhlt01" diff --git a/esphome/components/zhlt01/zhlt01.cpp b/esphome/components/zhlt01/zhlt01.cpp index 2585ac1b57..afca7bf860 100644 --- a/esphome/components/zhlt01/zhlt01.cpp +++ b/esphome/components/zhlt01/zhlt01.cpp @@ -3,7 +3,7 @@ namespace esphome::zhlt01 { -static const char *const TAG = "zhlt01.climate"; +ESPHOME_LOG_TAG(TAG, "zhlt01.climate"); void ZHLT01Climate::transmit_state() { uint8_t ir_message[12] = {0}; diff --git a/esphome/components/zigbee/__init__.py b/esphome/components/zigbee/__init__.py index 47913b34d7..44f83696f2 100644 --- a/esphome/components/zigbee/__init__.py +++ b/esphome/components/zigbee/__init__.py @@ -1,9 +1,10 @@ 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.const import CONF_MANUFACTURER +from esphome.components.esp32 import DOMAIN as ESP32_DOMAIN, only_on_variant from esphome.components.esp32.const import ( VARIANT_ESP32C5, VARIANT_ESP32C6, @@ -13,36 +14,52 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S31, ) import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START +from esphome.const import ( + CONF_ESPHOME, + CONF_ID, + CONF_INTERNAL, + CONF_MODEL, + CONF_NAME, + CONF_ON_START, + CONF_POLLING_INTERVAL, + CONF_PROJECT, + CONF_VERSION, + __version__ as ESPHOME_VERSION, +) from esphome.core import CORE, CoroPriority, coroutine_with_priority +import esphome.final_validate as fv from esphome.types import ConfigType from .const import ( + CONF_CLUSTER, CONF_ENDPOINT, CONF_MAX_EP_NUMBER, CONF_ON_JOIN, CONF_POWER_SOURCE, CONF_REPORT, CONF_ROUTER, + CONF_SLEEPY, CONF_USE_DEVICE_TYPE, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, REPORT, ZigbeeComponent, - zigbee_ns, ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, CONF_MAX_EP_NUMBER_ZEPHYR, - CONF_SLEEPY, CONF_ZIGBEE_ID, KEY_EP_NUMBER, ) 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 ( @@ -55,56 +72,112 @@ from .zigbee_zephyr import ( _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@luar123", "@tomaszduda23"] +DOMAIN = "zigbee" CONFLICTS_WITH = ["openthread"] def _check_report_deprecation(value: str) -> str: - if str(value).lower() in ("coordinator", "enable"): + if str(value).lower() == "coordinator": _LOGGER.warning( - "Report options 'coordinator' and 'enable' are deprecated and will be removed in a future release. Use 'default' instead." + "Report option 'coordinator' is deprecated and will be removed in a future release. Use 'default' instead." ) 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(DOMAIN), + cv.requires_component(ESP32_DOMAIN), + _check_report_deprecation, + cv.enum(REPORT, lower=True), + ), + cv.Optional(CONF_ENDPOINT): cv.All( + cv.requires_component(DOMAIN), + cv.requires_component(ESP32_DOMAIN), + cv.int_range(1, CONF_MAX_EP_NUMBER), + ), + cv.Optional(CONF_USE_DEVICE_TYPE): cv.All( + cv.requires_component(DOMAIN), + cv.requires_component(ESP32_DOMAIN), + cv.boolean, + ), + } + ) + if cluster_options: + schema = cv.Schema( + { + cv.OnlyWith( + CONF_CLUSTER, ["esp32", "zigbee"], default=cluster_options[0] + ): cv.All( + cv.requires_component(DOMAIN), + cv.requires_component(ESP32_DOMAIN), + 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: - if config.get(CONF_ROUTER) and config.get(CONF_SLEEPY): - raise cv.Invalid("router and sleepy are mutually exclusive") + if config.get(CONF_ROUTER): + if config.get(CONF_SLEEPY): + raise cv.Invalid("router and sleepy are mutually exclusive") + if config.get(CONF_POLLING_INTERVAL): + raise cv.Invalid(f"{CONF_POLLING_INTERVAL} is only valid for end devices.") return config +def _validate_ascii(value): + """Validate that a string contains only ascii characters.""" + if not isinstance(value, str): + raise cv.Invalid("expected string") + if not value.isascii(): + raise cv.Invalid("Only ASCII characters are allowed") + return value + + CONFIG_SCHEMA = cv.All( cv.Schema( { cv.GenerateID(CONF_ID): cv.declare_id(ZigbeeComponent), cv.Optional(CONF_MODEL, default=CORE.name): cv.All( - cv.string, cv.Length(max=31) + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional( + CONF_MANUFACTURER, default="esphome", visibility=cv.Visibility.YAML_ONLY + ): cv.All( + cv.string, + cv.Length(max=31), + _validate_ascii, + ), + cv.Optional(CONF_VERSION, default=0): cv.All( + cv.int_range(min=0x00, max=0xFF), ), cv.Optional(CONF_ROUTER, default=False): cv.boolean, cv.Optional(CONF_ON_JOIN): automation.validate_automation({}), @@ -126,9 +199,17 @@ CONFIG_SCHEMA = cv.All( ), cv.requires_component("nrf52"), ), - cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All( + cv.Optional(CONF_SLEEPY, default=False): cv.All( cv.boolean, ), + cv.Optional(CONF_POLLING_INTERVAL): cv.All( + cv.requires_component(ESP32_DOMAIN), + cv.positive_time_period_milliseconds, + cv.Range( + min=cv.TimePeriodMilliseconds(milliseconds=200), + max=cv.TimePeriodMinutes(minutes=60), # 1 hour + ), + ), } ).extend(cv.COMPONENT_SCHEMA), _validate_router_sleepy, @@ -155,7 +236,7 @@ CONFIG_SCHEMA = cv.All( def validate_number_of_ep(config: ConfigType) -> ConfigType: if not CORE.is_nrf52: return config - if KEY_ZIGBEE not in CORE.data: + if KEY_EP_NUMBER not in CORE.data.get(KEY_ZIGBEE, {}): raise cv.Invalid("At least one zigbee device need to be included") count = len(CORE.data[KEY_ZIGBEE][KEY_EP_NUMBER]) if count == 1: @@ -168,7 +249,38 @@ def validate_number_of_ep(config: ConfigType) -> ConfigType: return config +def final_validate_strings(config: ConfigType) -> ConfigType: + version_str = ESPHOME_VERSION + if ( + proj_version := fv.full_config.get() + .get(CONF_ESPHOME, {}) + .get(CONF_PROJECT, {}) + .get(CONF_VERSION) + ): + version_str = proj_version.encode("ascii", "ignore")[:15].decode() + if version_str != proj_version: + _LOGGER.warning( + "esphome.project.version %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + proj_version, + version_str, + ) + area_str = "" + if CORE.area: + area_str = CORE.area.encode("ascii", "ignore")[:15].decode() + if area_str != CORE.area: + _LOGGER.warning( + "esphome.area %s was changed to %s for use in Zigbee attributes. Use only a maximum of 15 ASCII characters", + CORE.area, + area_str, + ) + data: dict[str, Any] = CORE.data.setdefault(KEY_ZIGBEE, {}) + data[KEY_ZIGBEE_VERSION_STR] = version_str + data[KEY_ZIGBEE_AREA_STR] = area_str + return config + + FINAL_VALIDATE_SCHEMA = cv.All( + final_validate_strings, validate_number_of_ep, final_validate_esp32, ) @@ -197,30 +309,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 DOMAIN 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 DOMAIN 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 DOMAIN 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 +351,18 @@ async def setup_number( max_value: float, step: float, ) -> None: - if not config.get(CONF_ZIGBEE_ID) or config.get(CONF_INTERNAL): + if DOMAIN 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: @@ -254,7 +381,7 @@ def consume_endpoint(config: ConfigType) -> ConfigType: def validate_binary_sensor(config: ConfigType) -> ConfigType: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: return validate_binary_sensor_esp32(config) @@ -262,7 +389,7 @@ def validate_binary_sensor(config: ConfigType) -> ConfigType: def validate_sensor(config: ConfigType) -> ConfigType: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN not in CORE.loaded_integrations or config.get(CONF_INTERNAL): return config if CORE.is_esp32: return validate_sensor_esp32(config) @@ -270,18 +397,18 @@ def validate_sensor(config: ConfigType) -> ConfigType: def validate_switch(config: ConfigType) -> ConfigType: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN 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) def validate_number(config: ConfigType) -> ConfigType: - if "zigbee" not in CORE.loaded_integrations or config.get(CONF_INTERNAL): + if DOMAIN 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 +420,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..109b953de5 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" @@ -71,6 +72,7 @@ CONF_WIPE_ON_BOOT = "wipe_on_boot" CONF_REPORT = "report" CONF_ROUTER = "router" CONF_POWER_SOURCE = "power_source" +CONF_SLEEPY = "sleepy" CONF_USE_DEVICE_TYPE = "use_device_type" POWER_SOURCE = { "UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN @@ -83,6 +85,8 @@ POWER_SOURCE = { } KEY_ZIGBEE = "zigbee" +KEY_ZIGBEE_AREA_STR = "zigbee_area_str" +KEY_ZIGBEE_VERSION_STR = "zigbee_version_str" # BACnet engineering units mapping (ZCL uses BACnet unit codes) # See: https://github.com/zigpy/zha/blob/dev/zha/application/platforms/number/bacnet.py @@ -144,7 +148,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/const_zephyr.py b/esphome/components/zigbee/const_zephyr.py index bf8e8287c4..7177b9fc79 100644 --- a/esphome/components/zigbee/const_zephyr.py +++ b/esphome/components/zigbee/const_zephyr.py @@ -4,7 +4,6 @@ CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor" CONF_ZIGBEE_SENSOR = "zigbee_sensor" CONF_ZIGBEE_SWITCH = "zigbee_switch" CONF_ZIGBEE_NUMBER = "zigbee_number" -CONF_SLEEPY = "sleepy" CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui" # Keys for CORE.data storage diff --git a/esphome/components/zigbee/time/__init__.py b/esphome/components/zigbee/time/__init__.py index 74f81df2e9..0572e591cf 100644 --- a/esphome/components/zigbee/time/__init__.py +++ b/esphome/components/zigbee/time/__init__.py @@ -14,6 +14,7 @@ from ..zigbee_zephyr import ( ZigbeeClusterDesc, ZigbeeComponent, get_slot_index, + zigbee_assign, zigbee_new_attr_list, zigbee_new_cluster_list, zigbee_new_variable, @@ -81,6 +82,7 @@ async def _add_time_zephyr(config: ConfigType) -> None: # Create the binary attributes structure time_attrs = zigbee_new_variable(attrs_name, "zb_zcl_time_attrs_t") + zigbee_assign(time_attrs.time, cg.RawExpression("ZB_ZCL_TIME_TIME_INVALID_VALUE")) attr_list = zigbee_new_attr_list( attr_list_name, "ZB_ZCL_DECLARE_TIME_ATTR_LIST", diff --git a/esphome/components/zigbee/time/zigbee_time_esp32.cpp b/esphome/components/zigbee/time/zigbee_time_esp32.cpp index 5567295782..909c714c5e 100644 --- a/esphome/components/zigbee/time/zigbee_time_esp32.cpp +++ b/esphome/components/zigbee/time/zigbee_time_esp32.cpp @@ -1,11 +1,13 @@ #include "zigbee_time_esp32.h" #if defined(USE_ZIGBEE) && defined(USE_ESP32) && defined(USE_TIME) #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome::zigbee { -static const char *const TAG = "zigbee.time"; +ESPHOME_LOG_TAG(TAG, "zigbee.time"); + +static constexpr uint32_t REGISTER_RETRY_TIMEOUT_ID = 0; +static constexpr uint32_t SYNC_RETRY_TIMEOUT_ID = 1; // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). @@ -29,7 +31,7 @@ void ZigbeeTime::register_zb_time_() { }; ezb_err_t ret; if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { - this->set_timeout("zb_time_register", 100, [this]() { this->register_zb_time_(); }); + this->set_timeout(REGISTER_RETRY_TIMEOUT_ID, 100, [this]() { this->register_zb_time_(); }); return; } ret = ezb_zcl_time_server_interface_register(this->endpoint_, time_interface); @@ -69,7 +71,7 @@ void ZigbeeTime::update() { ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time, will retry maximum 3 times"); } if (this->retry_count_ < 3) { - this->set_timeout("zb_time_sync", 100, [this]() { this->update(); }); + this->set_timeout(SYNC_RETRY_TIMEOUT_ID, 100, [this]() { this->update(); }); this->retry_count_++; } else { ESP_LOGW(TAG, "Could not acquire Zigbee lock to synchronize time"); @@ -102,7 +104,6 @@ void ZigbeeTime::set_epoch_time(uint32_t utc) { ESP_LOGV(TAG, "Setting device time to UTC: %u", static_cast(utc)); this->synchronize_epoch_(utc); }); - App.wake_loop_threadsafe(); } void ZigbeeTime::dump_config() { diff --git a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp index 3f14d0a62d..0de0ee0ee4 100644 --- a/esphome/components/zigbee/time/zigbee_time_zephyr.cpp +++ b/esphome/components/zigbee/time/zigbee_time_zephyr.cpp @@ -1,21 +1,23 @@ #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" +#include namespace esphome::zigbee { -static const char *const TAG = "zigbee.time"; +ESPHOME_LOG_TAG(TAG, "zigbee.time"); // This time standard is the number of // seconds since 0 hrs 0 mins 0 sec on 1st January 2000 UTC (Universal Coordinated Time). constexpr time_t EPOCH_2000 = 946684800; +// Largest ZCL time that still fits the 32-bit epoch. Also rejects ZB_ZCL_TIME_TIME_INVALID_VALUE. +constexpr uint32_t MAX_ZCL_TIME = std::numeric_limits::max() - EPOCH_2000; ZigbeeTime *global_time = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t short_addr, zb_uint8_t endpoint, zb_uint32_t nw_time) { - if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT) { + if (status == RET_OK && auth_level >= ZB_ZCL_TIME_HAS_SYNCHRONIZED_BIT && nw_time <= MAX_ZCL_TIME) { global_time->set_epoch_time(nw_time + EPOCH_2000); } else if (status != RET_TIMEOUT || !global_time->has_time_) { ESP_LOGE(TAG, "Status: %d, auth_level: %u, short_addr: %d, endpoint: %d, nw_time: %u", status, auth_level, @@ -26,7 +28,6 @@ void ZigbeeTime::sync_time(zb_ret_t status, zb_uint32_t auth_level, zb_uint16_t void ZigbeeTime::setup() { global_time = this; this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); - synchronize_epoch_(EPOCH_2000); this->parent_->add_on_join_callback([this](bool x) { zb_zcl_time_server_synchronize(this->endpoint_, sync_time); }); } @@ -39,8 +40,12 @@ void ZigbeeTime::dump_config() { } void ZigbeeTime::update() { - time_t time = timestamp_now(); - this->cluster_attributes_->time = time - EPOCH_2000; + const time_t now = this->timestamp_now(); + if (now < EPOCH_2000) { + this->cluster_attributes_->time = ZB_ZCL_TIME_TIME_INVALID_VALUE; + } else { + this->cluster_attributes_->time = static_cast(now - EPOCH_2000); + } } void ZigbeeTime::set_epoch_time(uint32_t epoch) { @@ -48,7 +53,6 @@ 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) { @@ -63,8 +67,12 @@ void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) { if (cluster_id == ZB_ZCL_CLUSTER_ID_TIME) { if (attr_id == ZB_ZCL_ATTR_TIME_TIME_ID) { zb_uint32_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data32; - ESP_LOGI(TAG, "Synchronize time to %u", value); - this->defer([this, value]() { synchronize_epoch_(value + EPOCH_2000); }); + if (value <= MAX_ZCL_TIME) { + ESP_LOGI(TAG, "Synchronize time to %u", value); + this->defer([this, value]() { this->synchronize_epoch_(value + EPOCH_2000); }); + } else { + ESP_LOGW(TAG, "Ignoring invalid time %u", value); + } } else if (attr_id == ZB_ZCL_ATTR_TIME_TIME_STATUS_ID) { zb_uint8_t value = p_device_cb_param->cb_param.set_attr_value_param.values.data8; ESP_LOGI(TAG, "Time status %hd", value); diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.cpp b/esphome/components/zigbee/zigbee_attribute_esp32.cpp index 1fb8d1abe4..e2ff542976 100644 --- a/esphome/components/zigbee/zigbee_attribute_esp32.cpp +++ b/esphome/components/zigbee/zigbee_attribute_esp32.cpp @@ -6,7 +6,7 @@ namespace esphome::zigbee { -static const char *const TAG = "zigbee.attribute"; +ESPHOME_LOG_TAG(TAG, "zigbee.attribute"); void ZigbeeAttribute::set_attr_() { if (!this->zb_->is_started()) { @@ -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(); } @@ -61,6 +62,9 @@ void ZigbeeAttribute::report_(bool has_lock) { } void ZigbeeAttribute::set_report(ZigbeeReportT report) { + if (report == ZigbeeReportT::ZIGBEE_REPORT_DEFAULT || report == ZigbeeReportT::ZIGBEE_REPORT_COORDINATOR) { + return; + } this->report_enabled = true; if (report == ZigbeeReportT::ZIGBEE_REPORT_FORCE) { this->force_report_ = true; @@ -72,6 +76,14 @@ void ZigbeeAttribute::set_report(ZigbeeReportT report) { }); } +void ZigbeeAttribute::report() { + if (!this->report_enabled) { + return; + } + this->report_requested_ = true; + this->enable_loop(); +} + void ZigbeeAttribute::loop() { if (this->set_attr_requested_) { this->set_attr_(); diff --git a/esphome/components/zigbee/zigbee_attribute_esp32.h b/esphome/components/zigbee/zigbee_attribute_esp32.h index fc229b4e95..adaa60646d 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,27 @@ class ZigbeeAttribute final : public Component { template void set_attr(const T &value); uint8_t attr_type() { return attr_type_; } void set_report(ZigbeeReportT report); + void 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 +92,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 +112,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 +157,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_binary_sensor_zephyr.cpp b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp index 464cc04d62..911159d091 100644 --- a/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_binary_sensor_zephyr.cpp @@ -10,7 +10,7 @@ extern "C" { } namespace esphome::zigbee { -static const char *const TAG = "zigbee.binary_sensor"; +ESPHOME_LOG_TAG(TAG, "zigbee.binary_sensor"); ZigbeeBinarySensor::ZigbeeBinarySensor(binary_sensor::BinarySensor *binary_sensor) : binary_sensor_(binary_sensor) {} diff --git a/esphome/components/zigbee/zigbee_ep_esp32.py b/esphome/components/zigbee/zigbee_ep_esp32.py index 700267ef50..1616ce0ceb 100644 --- a/esphome/components/zigbee/zigbee_ep_esp32.py +++ b/esphome/components/zigbee/zigbee_ep_esp32.py @@ -1,81 +1,357 @@ 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 .const import ( - CONF_MAX_EP_NUMBER, - CONF_REPORT, - CONF_USE_DEVICE_TYPE, - KEY_ZIGBEE, - REPORT, +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, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE 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", + 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", + 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", + 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", + 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_REPORT: cv.enum(REPORT, lower=True)("default"), - CONF_DEVICE: None, + CONF_ATTRIBUTE_ID: 0x0, + CONF_TYPE: "INT16", + 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", + 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", + 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", + 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_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", + 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", + 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", + CONNECT: True, }, ], }, diff --git a/esphome/components/zigbee/zigbee_esp32.cpp b/esphome/components/zigbee/zigbee_esp32.cpp index 4f9c70da75..68c0d005b5 100644 --- a/esphome/components/zigbee/zigbee_esp32.cpp +++ b/esphome/components/zigbee/zigbee_esp32.cpp @@ -17,7 +17,9 @@ namespace esphome::zigbee { -static const char *const TAG = "zigbee"; +ESPHOME_LOG_TAG(TAG, "zigbee"); + +static constexpr uint32_t COMMISSIONING_RETRY_TIMEOUT_ID = 0; static ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -47,10 +49,16 @@ void ZigbeeComponent::factory_reset() { esp_zigbee_lock_release(); } +void ZigbeeComponent::report() { + for (const auto &[_, attribute] : this->attributes_) { + attribute->report(); + } +} + 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", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); - App.wake_loop_threadsafe(); + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 100, + [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); }); return; } if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) { @@ -86,10 +94,9 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { } } else { ESP_LOGW(TAG, "The %s failed with status(0x%02x), please retry", ezb_app_signal_to_string(signal_type), status); - global_zigbee->set_timeout("zb_init", 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION); }); - App.wake_loop_threadsafe(); } } break; case EZB_BDB_SIGNAL_STEERING: { @@ -107,15 +114,14 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { ESP_LOGD(TAG, "Failed to join network with status(0x%02x)", status); if (steering_retry_count < 10) { steering_retry_count++; - global_zigbee->set_timeout("zb_init", 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } else { - global_zigbee->set_timeout("zb_init", 600 * 1000, []() { + global_zigbee->set_timeout(COMMISSIONING_RETRY_TIMEOUT_ID, 600 * 1000, []() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING); }); } - App.wake_loop_threadsafe(); } } break; case EZB_ZDO_SIGNAL_LEAVE: { @@ -154,6 +160,31 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) { return true; } +void load_zb_event(ZBEvent *event, ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute) { + event->load_set_attr_value_event(info, attribute); +} + +template void enqueue_zb_event(Args... args) { + // Allocate an event from the pool + ZBEvent *event = global_zigbee->zb_event_pool_.allocate(); + if (event == nullptr) { + // No events available - queue is full or we're out of memory + global_zigbee->zb_events_.increment_dropped_count(); + return; + } + + // Load new event data (replaces previous event) + load_zb_event(event, args...); + + // Push the event to the queue + global_zigbee->zb_events_.push(event); + // Push always succeeds because we're the only producer and the pool ensures we never exceed queue size + global_zigbee->enable_loop_soon_any_context(); +} + +// Explicit template instantiations for the friend function +template void enqueue_zb_event(ezb_zcl_message_info_t info, ezb_zcl_attribute_t attribute); + static void zb_attribute_handler(ezb_zcl_set_attr_value_message_t *message) { ESP_RETURN_ON_FALSE(message, , TAG, "Empty message"); ESP_RETURN_ON_FALSE(message->info.status == EZB_ZCL_STATUS_SUCCESS, , TAG, "Received message: error status(%d)", @@ -161,6 +192,7 @@ static void zb_attribute_handler(ezb_zcl_set_attr_value_message_t *message) { ESP_LOGD(TAG, "ZCL SetAttributeValue message for endpoint(%d) cluster(0x%04x) %s with status(0x%02x)", message->info.dst_ep, message->info.cluster_id, message->info.cluster_role == EZB_ZCL_CLUSTER_SERVER ? "server" : "client", message->info.status); + enqueue_zb_event(message->info, message->in.attribute); } static void zb_action_handler(ezb_zcl_core_action_callback_id_t callback_id, void *message) { @@ -180,14 +212,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); } @@ -210,7 +244,8 @@ void ZigbeeComponent::add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint ESP_LOGD(TAG, "Endpoint %u: Added cluster 0x%04X with role %u", endpoint_id, cluster_id, role); } -void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source) { +void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufacturer, const char *location, + const char *sw_build_id, uint8_t power_source, uint8_t app_version) { char date_buf[16]; time_t time_val = App.get_build_time(); struct tm *timeinfo = localtime(&time_val); @@ -219,7 +254,10 @@ void ZigbeeComponent::set_basic_cluster(const char *model, const char *manufactu .model = get_zcl_string(model, 31), .manufacturer = get_zcl_string(manufacturer, 31), .date = get_zcl_string(date_buf, 15), + .location = get_zcl_string(location, 15), + .sw_build_id = get_zcl_string(sw_build_id, 15), .power_source = power_source, + .app_version = app_version, }; } @@ -232,13 +270,23 @@ 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); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_APPLICATION_VERSION_ID, + &(this->basic_cluster_data_.app_version)); + uint8_t stack_version = 0; + uint8_t hw_version = 0; + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_STACK_VERSION_ID, &stack_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_HW_VERSION_ID, &hw_version); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_LOCATION_DESCRIPTION_ID, + this->basic_cluster_data_.location); + ezb_zcl_basic_cluster_desc_add_attr(cluster_desc, EZB_ZCL_ATTR_BASIC_SW_BUILD_ID_ID, + this->basic_cluster_data_.sw_build_id); } bool ZigbeeComponent::register_device() { @@ -272,7 +320,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(), @@ -289,7 +339,7 @@ ZigbeeComponent::ZigbeeComponent() { #else esp_zigbee_zed_config_s zb_zed_cfg = { .ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN, - .keep_alive = ED_KEEP_ALIVE, + .keep_alive = this->keep_alive_, }; device_config.zed_config = zb_zed_cfg; #endif @@ -299,11 +349,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(); @@ -313,6 +359,8 @@ void ZigbeeComponent::setup() { #ifdef CONFIG_ZB_ZCZR ezb_bdb_set_router_rejoin_required(true); +#else + ezb_nwk_set_rx_on_when_idle(!this->sleepy_); // if sleepy, disable RX when idle #endif ezb_aps_secur_enable_distributed_security(false); @@ -341,12 +389,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; @@ -359,30 +440,54 @@ void ZigbeeComponent::loop() { } void ZigbeeComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Zigbee\n" + " Model: %.*s\n" + " Manufacturer: %.*s\n" +#ifdef CONFIG_ZB_ZCZR + " Router: %s\n" +#else + " Sleepy end device: %s\n" + " Poll interval: %" PRIu32 " ms\n" +#endif + " App Version: %u", + this->basic_cluster_data_.model[0], reinterpret_cast(this->basic_cluster_data_.model + 1), + this->basic_cluster_data_.manufacturer[0], + reinterpret_cast(this->basic_cluster_data_.manufacturer + 1), +#ifdef CONFIG_ZB_ZCZR + YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), +#else + YESNO(this->sleepy_), this->keep_alive_, +#endif + this->basic_cluster_data_.app_version); if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) { ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n" " Device is joined to the network: %s\n" " Current channel: %d\n" " Short addr: 0x%04X\n" " Short pan id: 0x%04X", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()), - ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid()); + YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), + ezb_nwk_get_panid()); esp_zigbee_lock_release(); - } else { - ESP_LOGCONFIG(TAG, - "Zigbee\n" - " Model: %.*s\n" - " Router: %s\n", - this->basic_cluster_data_.model[0], - reinterpret_cast(this->basic_cluster_data_.model + 1), - 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..d5ed7439bd 100644 --- a/esphome/components/zigbee/zigbee_esp32.h +++ b/esphome/components/zigbee/zigbee_esp32.h @@ -12,34 +12,40 @@ #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 { /* Zigbee configuration */ -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; - void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source); + void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; } + void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; } + void set_basic_cluster(const char *model, const char *manufacturer, const char *location, const char *sw_build_id, + uint8_t power_source, uint8_t app_version); void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role); void create_default_cluster(uint8_t endpoint_id, uint16_t device_id); bool register_device(); @@ -55,6 +61,7 @@ class ZigbeeComponent final : public Component { static void esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode); void factory_reset(); + void report(); template void add_on_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } template void add_on_start_callback(F &&cb) { this->start_cb_.add(std::forward(cb)); } @@ -69,11 +76,16 @@ class ZigbeeComponent final : public Component { bool is_joined() { return this->joined_; } protected: + bool sleepy_{false}; + uint32_t keep_alive_{3000}; struct { uint8_t *model; uint8_t *manufacturer; uint8_t *date; + uint8_t *location; + uint8_t *sw_build_id; uint8_t power_source; + uint8_t app_version; } basic_cluster_data_; #ifdef CONFIG_ZB_ZED ezb_nwk_device_type_t device_role_ = EZB_NWK_DEVICE_TYPE_END_DEVICE; @@ -84,18 +96,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 +135,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 +170,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 9aae8d4557..99f075ecb2 100644 --- a/esphome/components/zigbee/zigbee_esp32.py +++ b/esphome/components/zigbee/zigbee_esp32.py @@ -3,13 +3,17 @@ import logging import re from typing import Any +from esphome import automation import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.esp32 import ( CONF_PARTITIONS, + DOMAIN as ESP32_DOMAIN, add_idf_component, add_idf_sdkconfig_option, add_partition, include_builtin_idf_component, + require_mbedtls_ecp, require_mbedtls_tls_extras, require_vfs_select, ) @@ -17,16 +21,19 @@ 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, + CONF_POLLING_INTERVAL, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, + CONF_VERSION, CONF_WIFI, + DEVICE_CLASS_OUTLET, ) from esphome.core import CORE from esphome.coroutine import CoroPriority, coroutine_with_priority @@ -35,32 +42,50 @@ 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, CONF_ROUTER, + CONF_SLEEPY, CONF_USE_DEVICE_TYPE, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, ZigbeeAttribute, + ZigbeeComponent, ) 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__) @@ -79,8 +104,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 @@ -92,8 +120,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") @@ -104,6 +135,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 @@ -167,33 +206,88 @@ 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 CONF_REPORT in config: - attr[CONF_REPORT] = config[CONF_REPORT] + if attr.get(CONNECT): # connect device + if (report := config.get(CONF_REPORT, "default")) not in ( + "default", + "coordinator", + ): + attr[CONF_REPORT] = 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", }, ) @@ -204,22 +298,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 @@ -274,21 +386,20 @@ 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. @@ -313,11 +424,18 @@ async def esp32_to_code(config: ConfigType) -> "MockObj": # setup zigbee components var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + if config.get(CONF_SLEEPY): + cg.add(var.set_sleepy(True)) + if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000: + cg.add(var.set_keep_alive(keep_alive)) cg.add( var.set_basic_cluster( config[CONF_MODEL], - "esphome", + config[CONF_MANUFACTURER], + zb_data[KEY_ZIGBEE_AREA_STR], + zb_data[KEY_ZIGBEE_VERSION_STR], POWER_SOURCE[config[CONF_POWER_SOURCE]], + config[CONF_VERSION], ) ) for ep_num, ep in ep_dict.items(): @@ -332,3 +450,37 @@ 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)) + + +ZIGBEE_ACTION_SCHEMA_ESP32 = automation.maybe_simple_id( + cv.All( + cv.Schema( + { + cv.GenerateID(): cv.use_id(ZigbeeComponent), + }, + ), + cv.requires_component(ESP32_DOMAIN), + ) +) + +automation.register_apply_action( + "zigbee.report", + ZIGBEE_ACTION_SCHEMA_ESP32, + automation.ApplyCall("report()"), +) 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_number_zephyr.cpp b/esphome/components/zigbee/zigbee_number_zephyr.cpp index ceb318480c..49b03d3ec9 100644 --- a/esphome/components/zigbee/zigbee_number_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_number_zephyr.cpp @@ -10,7 +10,7 @@ extern "C" { } namespace esphome::zigbee { -static const char *const TAG = "zigbee.number"; +ESPHOME_LOG_TAG(TAG, "zigbee.number"); void ZigbeeNumber::setup() { this->parent_->add_callback(this->endpoint_, [this](zb_bufid_t bufid) { this->zcl_device_cb_(bufid); }); diff --git a/esphome/components/zigbee/zigbee_sensor_zephyr.cpp b/esphome/components/zigbee/zigbee_sensor_zephyr.cpp index 25e1e083e0..9e16cb6a0c 100644 --- a/esphome/components/zigbee/zigbee_sensor_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_sensor_zephyr.cpp @@ -10,7 +10,7 @@ extern "C" { } namespace esphome::zigbee { -static const char *const TAG = "zigbee.sensor"; +ESPHOME_LOG_TAG(TAG, "zigbee.sensor"); ZigbeeSensor::ZigbeeSensor(sensor::Sensor *sensor) : sensor_(sensor) {} diff --git a/esphome/components/zigbee/zigbee_switch_zephyr.cpp b/esphome/components/zigbee/zigbee_switch_zephyr.cpp index 935140e9df..286a036fc6 100644 --- a/esphome/components/zigbee/zigbee_switch_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_switch_zephyr.cpp @@ -13,7 +13,7 @@ extern "C" { namespace esphome::zigbee { -static const char *const TAG = "zigbee_on_off.switch"; +ESPHOME_LOG_TAG(TAG, "zigbee_on_off.switch"); void ZigbeeSwitch::dump_config() { ESP_LOGCONFIG(TAG, diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index 286c83b8f5..1b2d7392df 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -1,7 +1,6 @@ #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" @@ -16,7 +15,7 @@ extern "C" { namespace esphome::zigbee { -static const char *const TAG = "zigbee"; +ESPHOME_LOG_TAG(TAG, "zigbee"); ZigbeeComponent *global_zigbee = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -120,8 +119,6 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) { /* Set default response value. */ p_device_cb_param->status = RET_OK; - App.wake_loop_threadsafe(); - // endpoints are enumerated from 1 if (global_zigbee->callbacks_.size() >= endpoint) { const auto &cb = global_zigbee->callbacks_[endpoint - 1]; @@ -138,7 +135,6 @@ 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_() { @@ -146,7 +142,6 @@ void ZigbeeComponent::on_start_() { ESP_LOGD(TAG, "Started zigbee stack"); this->start_cb_.call(); }); - App.wake_loop_threadsafe(); } #ifdef USE_ZIGBEE_WIPE_ON_BOOT @@ -199,6 +194,7 @@ void ZigbeeComponent::setup() { zigbee_configure_sleepy_behavior(this->sleepy_); #endif zigbee_enable(); + this->disable_loop(); } #ifdef ESPHOME_LOG_HAS_CONFIG @@ -263,7 +259,10 @@ static void send_attribute_report(zb_bufid_t bufid, zb_uint16_t cmd_id) { zb_buf_free(bufid); } -void ZigbeeComponent::force_report() { this->force_report_ = true; } +void ZigbeeComponent::force_report() { + this->force_report_ = true; + this->enable_loop_soon_any_context(); +} void ZigbeeComponent::add_radio_sleep_time_ms(uint32_t ms) { this->radio_sleep_remainder_ += ms; @@ -277,6 +276,7 @@ void ZigbeeComponent::loop() { this->force_report_ = false; zb_buf_get_out_delayed_ext(send_attribute_report, 0, 0); } + this->disable_loop(); } void ZigbeeComponent::factory_reset() { diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index f47cf6bd40..d468c9881d 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -2,6 +2,7 @@ import datetime import random import esphome.codegen as cg +from esphome.components.const import CONF_MANUFACTURER from esphome.components.zephyr import zephyr_add_prj_conf import esphome.config_validation as cv from esphome.const import ( @@ -9,7 +10,7 @@ from esphome.const import ( CONF_MODEL, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - __version__, + CONF_VERSION, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.cpp_generator import ( @@ -24,8 +25,11 @@ from .const import ( BACNET_UNITS, CONF_POWER_SOURCE, CONF_ROUTER, + CONF_SLEEPY, CONF_WIPE_ON_BOOT, KEY_ZIGBEE, + KEY_ZIGBEE_AREA_STR, + KEY_ZIGBEE_VERSION_STR, POWER_SOURCE, AnalogAttrs, AnalogAttrsOutput, @@ -35,7 +39,6 @@ from .const import ( ) from .const_zephyr import ( CONF_IEEE802154_VENDOR_OUI, - CONF_SLEEPY, CONF_ZIGBEE_BINARY_SENSOR, CONF_ZIGBEE_ID, CONF_ZIGBEE_NUMBER, @@ -158,15 +161,16 @@ async def zephyr_to_code(config: ConfigType) -> "MockObj": async def _attr_to_code(config: ConfigType) -> None: # Create the basic attributes structure and attribute list + zb_data = CORE.data.get(KEY_ZIGBEE, {}) basic_attrs = zigbee_new_variable("zigbee_basic_attrs", ZB_ZCL_BASIC_ATTRS_EXT_T) zigbee_new_attr_list( "zigbee_basic_attrib_list", "ZB_ZCL_DECLARE_BASIC_ATTRIB_LIST_EXT", zigbee_assign(basic_attrs.zcl_version, cg.RawExpression("ZB_ZCL_VERSION")), - zigbee_assign(basic_attrs.app_version, 0), + zigbee_assign(basic_attrs.app_version, config[CONF_VERSION]), zigbee_assign(basic_attrs.stack_version, 0), zigbee_assign(basic_attrs.hw_version, 0), - zigbee_set_string(basic_attrs.mf_name, "esphome"), + zigbee_set_string(basic_attrs.mf_name, config[CONF_MANUFACTURER]), zigbee_set_string(basic_attrs.model_id, config[CONF_MODEL]), zigbee_set_string( basic_attrs.date_code, @@ -178,11 +182,11 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.power_source, POWER_SOURCE[config[CONF_POWER_SOURCE]], ), - zigbee_set_string(basic_attrs.location_id, ""), + zigbee_set_string(basic_attrs.location_id, zb_data[KEY_ZIGBEE_AREA_STR]), zigbee_assign( basic_attrs.ph_env, cg.RawExpression("ZB_ZCL_BASIC_ENV_UNSPECIFIED") ), - zigbee_set_string(basic_attrs.sw_ver, __version__), + zigbee_set_string(basic_attrs.sw_ver, zb_data[KEY_ZIGBEE_VERSION_STR]), ) # Create the identify attributes structure and attribute list @@ -314,18 +318,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 +396,7 @@ async def _add_zigbee_ep( cg.add(var.set_parent(hub)) -async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: +async def add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: await _add_zigbee_ep( entity, config, @@ -416,7 +408,7 @@ async def _add_binary_sensor(entity: cg.MockObj, config: ConfigType) -> None: ) -async def _add_sensor(entity: cg.MockObj, config: ConfigType) -> None: +async def add_sensor(entity: cg.MockObj, config: ConfigType) -> None: # Get BACnet engineering unit from unit_of_measurement unit = config.get(CONF_UNIT_OF_MEASUREMENT, "") bacnet_unit = BACNET_UNITS.get(unit, BACNET_UNIT_NO_UNITS) @@ -433,7 +425,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/zio_ultrasonic/__init__.py b/esphome/components/zio_ultrasonic/__init__.py index e69de29bb2..7b2178dc7c 100644 --- a/esphome/components/zio_ultrasonic/__init__.py +++ b/esphome/components/zio_ultrasonic/__init__.py @@ -0,0 +1 @@ +DOMAIN = "zio_ultrasonic" diff --git a/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp b/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp index 1fa995b3dc..e03f2c5d31 100644 --- a/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp +++ b/esphome/components/zio_ultrasonic/zio_ultrasonic.cpp @@ -5,7 +5,7 @@ namespace esphome::zio_ultrasonic { -static const char *const TAG = "zio_ultrasonic"; +ESPHOME_LOG_TAG(TAG, "zio_ultrasonic"); void ZioUltrasonicComponent::dump_config() { ESP_LOGCONFIG(TAG, "Zio Ultrasonic Sensor:"); diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index 660e3f49a0..e451fe79f4 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components import uart +from esphome.components.wifi import DOMAIN as WIFI_DOMAIN import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_POWER_SAVE_MODE, CONF_WIFI import esphome.final_validate as fv @@ -7,6 +8,7 @@ from esphome.types import ConfigType CODEOWNERS = ["@kbx81"] DEPENDENCIES = ["api", "uart"] +DOMAIN = "zwave_proxy" zwave_proxy_ns = cg.esphome_ns.namespace("zwave_proxy") ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) @@ -14,7 +16,7 @@ ZWaveProxy = zwave_proxy_ns.class_("ZWaveProxy", cg.Component, uart.UARTDevice) def final_validate(config: ConfigType) -> None: full_config = fv.full_config.get() - if (wifi_conf := full_config.get(CONF_WIFI)) and ( + if (wifi_conf := full_config.get(WIFI_DOMAIN)) and ( wifi_conf.get(CONF_POWER_SAVE_MODE).lower() != "none" ): raise cv.Invalid( diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index b0d18a3e6a..bfa16f857a 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -12,7 +12,7 @@ namespace esphome::zwave_proxy { -static const char *const TAG = "zwave_proxy"; +ESPHOME_LOG_TAG(TAG, "zwave_proxy"); // Maximum bytes to log in very verbose hex output (168 * 3 = 504, under TX buffer size of 512) static constexpr size_t ZWAVE_MAX_LOG_BYTES = 168; diff --git a/esphome/components/zyaura/__init__.py b/esphome/components/zyaura/__init__.py index e69de29bb2..dcabd7a6bd 100644 --- a/esphome/components/zyaura/__init__.py +++ b/esphome/components/zyaura/__init__.py @@ -0,0 +1 @@ +DOMAIN = "zyaura" 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/components/zyaura/zyaura.cpp b/esphome/components/zyaura/zyaura.cpp index 0b834de90a..5f203b1893 100644 --- a/esphome/components/zyaura/zyaura.cpp +++ b/esphome/components/zyaura/zyaura.cpp @@ -3,7 +3,7 @@ namespace esphome::zyaura { -static const char *const TAG = "zyaura"; +ESPHOME_LOG_TAG(TAG, "zyaura"); bool IRAM_ATTR ZaDataProcessor::decode(uint32_t ms, bool data) { // check if a new message has started, based on time since previous bit diff --git a/esphome/config.py b/esphome/config.py index 13ec744ce4..682557ecfc 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -12,6 +12,7 @@ import re from typing import TYPE_CHECKING, Any import voluptuous as vol +import yaml from esphome import core, loader, pins, yaml_util from esphome.components.substitutions import do_substitution_pass @@ -26,8 +27,9 @@ from esphome.const import ( CONF_PACKAGES, CONF_PLATFORM, CONF_SUBSTITUTIONS, + ErrorFormat, ) -from esphome.core import CORE, DocumentRange, EsphomeError +from esphome.core import CORE, DocumentLocation, DocumentRange, EsphomeError # `esphome.core.config` is imported lazily at its two use sites below. # It pulls in `esphome.automation` and `esphome.config_validation`, which @@ -38,7 +40,7 @@ from esphome.loader import ComponentManifest, get_component, get_platform from esphome.log import AnsiFore, color from esphome.types import ConfigFragmentType, ConfigType from esphome.util import OrderedDict, safe_print -from esphome.voluptuous_schema import ExtraKeysInvalid +from esphome.voluptuous_schema import ExtraKeysInvalid, KeyInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret if TYPE_CHECKING: @@ -285,12 +287,8 @@ class Config(OrderedDict, fv.FinalValidateConfig): last_root = max( i for i, v in enumerate(error.path) if v is cv.ROOT_CONFIG_PATH ) - # can't change the path so re-create the error - error = vol.Invalid( - message=error.error_message, - path=error.path[last_root + 1 :], - error_type=error.error_type, - ) + # Trim in place, as Invalid.prepend() does, to keep the subclass + error._path = error.path[last_root + 1 :] # pylint: disable=protected-access self.errors.append(error) def add_validation_step(self, step: ConfigValidationStep): @@ -1129,6 +1127,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 +1260,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) @@ -1476,6 +1511,7 @@ class InvalidYAMLError(EsphomeError): message = f"Invalid YAML syntax:\n\n{base}" super().__init__(message) self.base_exc = base_exc + self.base_message = base def _load_config( @@ -1518,6 +1554,86 @@ def load_config( raise EsphomeError(f"Error while parsing config: {err}") from err +def get_invalid_range(res: Config, invalid: vol.Invalid) -> DocumentRange | None: + """Return the source range of the YAML that caused *invalid*.""" + return res.get_deepest_document_range_for_path( + invalid.path, isinstance(invalid, KeyInvalid) + ) + + +def _format_location(location: DocumentLocation | None) -> str: + """Return a 1-based `file:line:column`, or the main config file if unknown.""" + if location is None: + return str(CORE.config_path) + return f"{location.document}:{location.line + 1}:{location.column + 1}" + + +def _print_line_message(location: str, kind: str, message: object) -> None: + """Print *message* as `location: kind: text`, one line per message line. + + Lines after the first become `note:` lines, so tools that read one + message per line still see every line with its location. + """ + lines = [line.strip() for line in str(message).splitlines() if line.strip()] + first, *rest = lines or [""] + safe_print(f"{location}: {kind}: {first}") + for line in rest: + safe_print(f"{location}: note: {line}") + + +def _find_marked_yaml_error(err: BaseException) -> yaml.MarkedYAMLError | None: + cause: BaseException | None = err + while cause is not None and not isinstance(cause, yaml.MarkedYAMLError): + cause = cause.__cause__ + return cause + + +def _print_marked_yaml_error(err: yaml.MarkedYAMLError) -> None: + # Errors raised as MarkedYAMLError(message, mark) put the message in context + mark = err.problem_mark or err.context_mark + location = DocumentLocation.from_mark(mark) if mark is not None else None + if not (message := err.problem or err.context): + # str() includes the note, so it is not printed separately + _print_line_message(_format_location(location), "error", str(err)) + return + _print_line_message(_format_location(location), "error", message) + if err.problem and err.context and err.context_mark is not None: + context_location = DocumentLocation.from_mark(err.context_mark) + _print_line_message(_format_location(context_location), "note", err.context) + if err.note: + _print_line_message(_format_location(location), "note", err.note) + + +def _print_line_errors(res: Config) -> None: + for err in res.errors: + doc_range = get_invalid_range(res, err) + location = _format_location( + doc_range.start_mark if doc_range is not None else None + ) + # A YAML error in an included file is wrapped in a validation error + if (yaml_err := _find_marked_yaml_error(err)) is not None: + _print_marked_yaml_error(yaml_err) + _print_line_message(location, "note", "included from here") + elif isinstance(err, cv.SourceTraceInvalid): + # The trace only repeats the location this line already starts with + _print_line_message(location, "error", humanize_error(res, err.detail)) + else: + _print_line_message(location, "error", _format_vol_invalid(err, res)) + if doc_range is None and err.path: + # No source position, so give the config path to help find it + path = "->".join(str(item) for item in err.path) + _print_line_message(location, "note", f"In: {path}") + + +def _print_line_load_error(err: EsphomeError) -> None: + if (yaml_err := _find_marked_yaml_error(err)) is not None: + _print_marked_yaml_error(yaml_err) + else: + # Drop the "Invalid YAML syntax" heading; it is wrong for unreadable files + message = err.base_message if isinstance(err, InvalidYAMLError) else err + _print_line_message(_format_location(None), "error", message) + + def line_info(config, path, highlight=True): """Display line config source.""" if not highlight: @@ -1666,7 +1782,13 @@ def read_config( snapshot_user_config=snapshot_user_config, ) except EsphomeError as err: - _LOGGER.error("Error while reading config: %s", err) + if CORE.error_format == ErrorFormat.LINE: + _print_line_load_error(err) + else: + _LOGGER.error("Error while reading config: %s", err) + return None + if res.errors and CORE.error_format == ErrorFormat.LINE: + _print_line_errors(res) return None if res.errors: if not CORE.verbose: diff --git a/esphome/config_validation.py b/esphome/config_validation.py index be1352fa0f..bcfe5538cb 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -113,8 +113,14 @@ from esphome.schema_extractors import ( # Deprecated re-export for external components; remove before 2027.2.0 # pylint: disable-next=unused-import from esphome.util import parse_esphome_version # noqa: F401 -from esphome.voluptuous_schema import _Schema -from esphome.yaml_util import SensitiveStr, make_data_base +from esphome.voluptuous_schema import KeyInvalid, _Schema +from esphome.yaml_util import ( + Sensitive, + SensitiveHexInt, + SensitiveInt, + SensitiveStr, + make_data_base, +) if typing.TYPE_CHECKING: from esphome.types import ConfigType @@ -452,6 +458,20 @@ def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schem return schema.extend(overrides) +class SourceTraceInvalid(Invalid): + """An error whose message is followed by where the bad value came from. + + ``detail`` is the message without that trace, for output that already + gives the location. + """ + + def __init__( + self, detail: str, source_trace: str, path: list[typing.Hashable] | None = None + ) -> None: + super().__init__(f"{detail}\n{source_trace}", path) + self.detail = detail + + class FinalExternalInvalid(Invalid): """Represents an invalid value in the final validation phase where the path should not be prepended.""" @@ -548,11 +568,17 @@ class SensitiveValidator: def __call__(self, value: typing.Any) -> typing.Any: validated = self.inner(value) - # Tag string results so yaml_util.dump can mask them. Non-string - # results pass through unchanged; already-tagged values are not - # re-wrapped to keep nested cv.sensitive applications idempotent. - if isinstance(validated, str) and not isinstance(validated, SensitiveStr): + # Already-tagged values are returned as-is so nested cv.sensitive is + # idempotent; bool is an int subclass and must stay bool. Other types + # (e.g. lambdas) pass through unmasked. + if isinstance(validated, (Sensitive, bool)): + return validated + if isinstance(validated, str): return SensitiveStr(validated) + if isinstance(validated, int): + return (SensitiveHexInt if isinstance(validated, HexInt) else SensitiveInt)( + validated + ) return validated def __repr__(self) -> str: @@ -2300,7 +2326,7 @@ def validate_registry_entry(name, registry): if key is None: raise Invalid(f"Key missing from {name}! Got {value}") if key not in registry: - raise Invalid(f"Unable to find {name} with the name '{key}'", [key]) + raise KeyInvalid(f"Unable to find {name} with the name '{key}'", [key]) key2 = next((x for x in value if x != key and x not in ignore_keys), None) if key2 is not None: raise Invalid( @@ -2879,9 +2905,14 @@ def rename_key( When ``removed_in`` is set, a deprecation warning is logged if the old key is present. Pass ``component`` (the platform/component name) alongside ``removed_in`` so the warning identifies where it originates. + + Input that is not a dictionary is returned unchanged, so the schema that + follows reports it as a normal configuration error. """ def validator(config: dict) -> dict: + if not isinstance(config, dict): + return config config = config.copy() if old_key in config: has_at_most_one_key(old_key, new_key)(config) diff --git a/esphome/const.py b/esphome/const.py index 5ffbf8c49a..115ccad747 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.10.0-dev" +__version__ = "2026.11.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( @@ -23,12 +23,23 @@ 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" + + +class ErrorFormat(StrEnum): + """How configuration errors are printed.""" + + # Errors embedded in a dump of the failing YAML + YAML = "yaml" + # One `file:line:col: error: message` line per error + LINE = "line" # Toolchains that drive their build natively and never read platformio.ini. # SDK_NRF is absent on purpose: the zephyr backend keeps consuming # platformio_options. -NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO}) +NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO, Toolchain.HOST}) class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 5fcad90a81..61092ec427 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -30,6 +30,7 @@ from esphome.const import ( PLATFORM_NRF52, PLATFORM_RP2, PLATFORM_RTL87XX, + ErrorFormat, Toolchain, ) @@ -52,8 +53,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 +386,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 +396,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 +438,7 @@ class ID: is_declaration=self.is_declaration, type=self.type, is_manual=self.is_manual, + match_config=self.match_config, ) @@ -589,6 +597,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 @@ -672,6 +682,8 @@ class EsphomeCore: self.verbose = False # Whether ESPHome was started in quiet mode self.quiet = False + # How configuration errors are printed + self.error_format: ErrorFormat = ErrorFormat.YAML # A list of all known ID classes self.id_classes = {} # The current component being processed during validation @@ -692,6 +704,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 @@ -724,6 +737,7 @@ class EsphomeCore: self._config_hash = None self.skip_external_update = False self.toolchain = None + self.error_format = ErrorFormat.YAML PIN_SCHEMA_REGISTRY.reset() @contextmanager @@ -996,6 +1010,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 +1229,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/alloc_helpers.h b/esphome/core/alloc_helpers.h index 1da3162333..6f824b5383 100644 --- a/esphome/core/alloc_helpers.h +++ b/esphome/core/alloc_helpers.h @@ -10,12 +10,16 @@ /// Prefer the stack-based alternatives documented on each function instead. /// New code should avoid using these functions. +#include #include #include #include #include +#include #include +#include "esphome/core/helpers.h" + namespace esphome { // --- String helpers (allocating) --- @@ -70,6 +74,19 @@ std::string format_hex(const uint8_t *data, size_t length); /// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. std::string format_hex(const std::vector &data); +/// Format an unsigned integer in lowercased hex, starting with the most significant byte. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +template::value, int> = 0> std::string format_hex(T val) { + val = convert_big_endian(val); + return format_hex(reinterpret_cast(&val), sizeof(T)); +} + +/// Format the std::array \p data in lowercased hex. +/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. +template std::string format_hex(const std::array &data) { + return format_hex(data.data(), data.size()); +} + /// Format a byte array in pretty-printed, human-readable hex format. /// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. std::string format_hex_pretty(const uint8_t *data, size_t length, char separator = '.', bool show_length = true); @@ -90,10 +107,25 @@ std::string format_hex_pretty(const std::vector &data, char separator /// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. std::string format_hex_pretty(const std::string &data, char separator = '.', bool show_length = true); +/// Format an unsigned integer in pretty-printed, human-readable hex format. +/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. +template::value, int> = 0> +std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { + val = convert_big_endian(val); + return format_hex_pretty(reinterpret_cast(&val), sizeof(T), separator, show_length); +} + /// Format the byte array \p data of length \p len in binary. /// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. std::string format_bin(const uint8_t *data, size_t length); +/// Format an unsigned integer in binary, starting with the most significant byte. +/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. +template::value, int> = 0> std::string format_bin(T val) { + val = convert_big_endian(val); + return format_bin(reinterpret_cast(&val), sizeof(T)); +} + // --- Base64 helpers (allocating) --- /// Encode a byte buffer to base64 string. diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 50d1c61959..2ddc97efb5 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -36,7 +36,7 @@ namespace esphome { -static const char *const TAG = "app"; +ESPHOME_LOG_TAG(TAG, "app"); // Delay after setup() finishes before trimming the scheduler freelist of its post-boot peak. // 10 s is well past the bulk of post-setup async work (Wi-Fi/MQTT connects, first-read latency). diff --git a/esphome/core/application.h b/esphome/core/application.h index 8ed4c09096..f9f98a6707 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: @@ -389,6 +390,7 @@ class Application { friend Component; friend class Scheduler; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; #ifdef USE_RUNTIME_STATS friend class runtime_stats::RuntimeStatsCollector; #endif @@ -631,6 +633,38 @@ class LoopBlockingGuard { static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time); }; +/// Leaves a stretch of the current loop pass out of the blocking warning. +/// +/// Only for work that cannot be made shorter or split across passes: radio +/// bring-up, a first network connect, a key generation. Work that could be +/// cached, deferred, polled or turned into a state machine stays outside, so +/// the warning keeps finding it. +/// +/// Affects only work timed by LoopBlockingGuard: loop() and scheduler +/// callbacks, not setup(). Main loop task only. The watchdog is not fed, so +/// pair long work with watchdog::WatchdogManager. Scopes may nest. +/// Afterwards App.get_loop_component_start_time() can be earlier than a +/// millis() read taken before the scope; time the work with millis() at both ends. +/// +/// UnavoidableBlockingScope scope; +/// this->generate_key_(); +class UnavoidableBlockingScope { + public: + UnavoidableBlockingScope() : started_(MillisInternal::get()), pass_start_(App.get_loop_component_start_time()) {} + ~UnavoidableBlockingScope() { + // Move the pass start seen at entry forward by the time spent here, clamped to now + const uint32_t now = MillisInternal::get(); + const uint32_t moved = this->pass_start_ + (now - this->started_); + App.set_loop_component_start_time_(static_cast(now - moved) < 0 ? now : moved); + } + UnavoidableBlockingScope(const UnavoidableBlockingScope &) = delete; + UnavoidableBlockingScope &operator=(const UnavoidableBlockingScope &) = delete; + + private: + uint32_t started_; + uint32_t pass_start_; +}; + // Phase A: drain wake notifications and run the scheduler. Invoked on every // Application::loop() tick regardless of whether a component phase runs, so // scheduler items fire at their requested cadence even when the caller has @@ -672,8 +706,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { #if defined(USE_LWIP_FAST_SELECT) && defined(ESPHOME_THREAD_MULTI_ATOMICS) // Pairs with the TCP/IP thread's SYS_ARCH_UNPROTECT release on rcvevent so // subsequent Socket::ready() checks in this iter observe the published state - // without a per-call memw. Wake is independent (xTaskNotifyGive/ - // ulTaskNotifyTake), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. + // without a per-call memw. Wake is independent (esphome_main_task_notify/ + // esphome_main_task_wait), so non-losing. Skipped on MULTI_NO_ATOMICS (e.g. // BK72xx) — that path keeps `volatile` in esphome_lwip_socket_has_data() // instead. std::atomic_thread_fence(std::memory_order_acquire); @@ -775,8 +809,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 +829,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 b6058925b4..bbb7f08fb8 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -8,6 +8,7 @@ #include "esphome/core/string_ref.h" #include #include +#include #include #include @@ -69,6 +70,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; @@ -341,10 +406,14 @@ template class TemplatableValue { /// Check if this holds a static string (const char* stored without allocation) /// The pointer is always directly readable (RAM or flash-mapped). /// Returns false for FLASH_STRING (PROGMEM on ESP8266, requires _P functions). + // Remove before 2027.4.0 + ESPDEPRECATED("Read the value with ref_or_copy_to() instead. Removed in 2027.4.0", "2026.10.0") bool is_static_string() const { return this->type_ == STATIC_STRING; } /// Get the static string pointer (only valid if is_static_string() returns true) /// The pointer is always directly readable — FLASH_STRING uses a separate type. + // Remove before 2027.4.0 + ESPDEPRECATED("Use ref_or_copy_to() instead. Removed in 2027.4.0", "2026.10.0") const char *get_static_string() const { return this->static_str_; } /// Check if the string value is empty without allocating. @@ -426,6 +495,13 @@ template class TemplatableValue { }; }; +/// How deferring actions store a trigger argument: a copy, since a referenced object may be gone once the trigger +/// returns, unless the type cannot be copied (e.g. an entity, which outlives the wait). A copyable `Base &` would be +/// stored sliced, so entity-like argument types must not be copyable. +template +using stored_trigger_arg_t = + std::conditional_t>, std::remove_cvref_t, T>; + /** Base class for all automation conditions. * * @tparam Ts The template parameters to pass when executing. @@ -486,7 +562,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; @@ -494,26 +570,28 @@ 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. int num_running_total() { int total = this->num_running_; - if (this->next_ != nullptr) - total += this->next_->num_running_total(); + if (Action *next = this->chain_next_()) + total += next->num_running_total(); return total; } protected: friend ActionList; - template friend class ContinuationAction; + /// For a branch owner (if, while), play() runs when the branch finishes. virtual void play(const Ts &...x) = 0; void play_next_(const Ts &...x) { if (this->num_running_ > 0) { this->num_running_--; - if (this->next_ != nullptr) { + if (this->next_is_owner_()) { + this->owner_()->play(x...); + } else if (this->next_ != nullptr) { this->next_->play_complex(x...); } } @@ -527,15 +605,24 @@ template class Action { virtual void stop() {} void stop_next_() { - if (this->next_ != nullptr) { - this->next_->stop_complex(); - } + if (Action *next = this->chain_next_()) + next->stop_complex(); } bool is_running_next_() { - if (this->next_ == nullptr) - return false; - return this->next_->is_running(); + Action *next = this->chain_next_(); + return next != nullptr && next->is_running(); + } + + // A branch's last action points at its owner with the low bit set. Only Action objects are + // tagged, never function pointers, which use that bit on ARM Thumb (#9283). + static constexpr uintptr_t OWNER_TAG = 1; + bool next_is_owner_() const { return reinterpret_cast(this->next_) & OWNER_TAG; } + /// Only valid when next_is_owner_(). + Action *owner_() const { return reinterpret_cast(reinterpret_cast(this->next_) - OWNER_TAG); } + Action *chain_next_() const { return this->next_is_owner_() ? nullptr : this->next_; } + static Action *tag_owner(Action *owner) { + return reinterpret_cast(reinterpret_cast(owner) + OWNER_TAG); } Action *next_{nullptr}; @@ -545,25 +632,30 @@ template class Action { int num_running_{0}; }; +// The vtable pointer gives every instantiation this alignment. +static_assert(alignof(Action<>) >= 2, "the owner tag needs a free low bit in Action object pointers"); + template class ActionList { public: void add_action(Action *action) { // Walk to end of chain - action lists are short and only built during setup() - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; - *tail = action; + *this->tail_() = action; } void add_actions(const std::initializer_list *> &actions) { // Find tail once, then append all actions in a single pass - Action **tail = &this->actions_; - while (*tail != nullptr) - tail = &(*tail)->next_; + Action **tail = this->tail_(); for (auto *action : actions) { *tail = action; tail = &action->next_; } } + /// Resume `owner` via play() when the list finishes; call once, after adding actions. + void set_owner(Action *owner) { + // Nothing to tag in an empty list + if (this->actions_ == nullptr) + return; + *this->tail_() = Action::tag_owner(owner); + } // Force-inline: part of the Trigger→Automation→ActionList forwarding // chain collapsed to reduce automation call stack depth. inline void play(const Ts &...x) ESPHOME_ALWAYS_INLINE { @@ -596,6 +688,17 @@ template class ActionList { template void play_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play(std::get(tuple)...); } + // Lists with an owner are complete; never append after set_owner() + Action **tail_() { + Action **tail = &this->actions_; + while (*tail != nullptr) { + // Not checked in release builds: only automation.py builds these lists, appending each action and + // then finishing the branch once, so appending after set_owner() would be a codegen bug. + ESPHOME_DEBUG_ASSERT(!(*tail)->next_is_owner_()); + tail = &(*tail)->next_; + } + return tail; + } Action *actions_{nullptr}; }; diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 703c7b40e3..c59e27f43a 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -21,6 +22,9 @@ template class AndCondition : public Condition explicit AndCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit AndCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (!condition->check(x...)) @@ -39,6 +43,9 @@ template class OrCondition : public Condition { explicit OrCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit OrCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { for (auto *condition : this->conditions_) { if (condition->check(x...)) @@ -66,6 +73,9 @@ template class XorCondition : public Condition explicit XorCondition(std::initializer_list *> conditions) { init_array_from(this->conditions_, conditions); } + // Codegen passes each condition as its own argument; a list literal would sit in .rodata (RAM on ESP8266). + template + requires(sizeof...(Cs) == N) explicit XorCondition(Cs *...conditions) : conditions_{conditions...} {} bool check(const Ts &...x) override { size_t result = 0; for (auto *condition : this->conditions_) { @@ -253,90 +263,48 @@ template class StatelessLambdaAction : public Action { void (*f_)(Ts...); }; -/// Runs one codegen-generated function that has the parent and every field baked in, so the -/// action holds one pointer. Args pass by const reference so a std::string arg is never copied; +/// 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 { +template class ApplyAction final : public Action { public: - using ApplyFn = void (*)(const std::remove_cvref_t &...); - explicit ApplyAction(ApplyFn apply) : apply_(apply) {} - - void play(const Ts &...x) override { this->apply_(x...); } - - protected: - ApplyFn apply_; + 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 { +template class ApplyCondition final : public Condition { public: - using CheckFn = bool (*)(const std::remove_cvref_t &...); - explicit ApplyCondition(CheckFn check) : check_(check) {} - - bool check(const Ts &...x) override { return this->check_(x...); } - - protected: - CheckFn check_; -}; - -/// 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). -template class ContinuationAction : public Action { - public: - explicit ContinuationAction(Action *parent) : parent_(parent) {} - - void play(const Ts &...x) override { this->parent_->play_next_(x...); } - - protected: - Action *parent_; -}; - -// Forward declaration for WhileLoopContinuation -template class WhileAction; - -/// Loop continuation for WhileAction that checks condition and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). -template class WhileLoopContinuation : public Action { - public: - explicit WhileLoopContinuation(WhileAction *parent) : parent_(parent) {} - - void play(const Ts &...x) override; - - protected: - WhileAction *parent_; -}; - -// Wraps a ContinuationAction when Enabled, empty otherwise. -// Lets IfAction elide the else continuation when HasElse is false. -template struct OptionalContinuation { - ContinuationAction action; - explicit OptionalContinuation(Action *parent) : action(parent) {} -}; -template struct OptionalContinuation { - explicit OptionalContinuation(Action * /*parent*/) {} + bool check(const Ts &...x) override { return Fn(x...); } }; template class IfAction : public Action { public: explicit IfAction(Condition *condition) : condition_(condition) {} - // Precondition: add_then/add_else must be called at most once per instance. - // Codegen always batches the full action list into a single call. Calling - // twice would re-append the same inline continuation pointer and form a - // self-loop in the next_ chain. + // Precondition: build each branch either with one add_then/add_else call, or with + // add_then_action/add_else_action calls followed by one finish_then/finish_else, never both. + // set_owner() closes the list; appending after it is a codegen bug. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->then_continuation_); + this->then_.set_owner(this); } void add_else(const std::initializer_list *> &actions) requires(HasElse) { this->else_.add_actions(actions); - this->else_.add_action(&this->else_continuation_.action); + this->else_.set_owner(this); } - void play_complex(const Ts &...x) override { + // Codegen appends branch actions one at a time, then finishes each branch once; a list literal + // would be a constant array in .rodata, which is RAM on ESP8266. + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.set_owner(this); } + void add_else_action(Action *action) requires(HasElse) { this->else_.add_action(action); } + void finish_else() requires(HasElse) { this->else_.set_owner(this); } + + void play_complex(const Ts &...x) final { this->num_running_++; + // The condition may stop this automation (e.g. script.stop in a lambda) if (this->condition_->check(x...)) { if (!this->then_.empty() && this->num_running_ > 0) { this->then_.play(x...); @@ -351,8 +319,8 @@ template class IfAction : public Action { this->play_next_(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ - } + // Branch-end resume hook + void play(const Ts &...x) override { this->play_next_(x...); } void stop() override { this->then_.stop(); @@ -364,25 +332,24 @@ template class IfAction : public Action { protected: Condition *condition_; ActionList then_; - ContinuationAction then_continuation_{this}; struct NoElse {}; [[no_unique_address]] std::conditional_t, NoElse> else_; - [[no_unique_address]] OptionalContinuation else_continuation_{this}; }; template class WhileAction : public Action { public: WhileAction(Condition *condition) : condition_(condition) {} - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then). void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); - this->then_.add_action(&this->loop_continuation_); + this->then_.set_owner(this); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.set_owner(this); } - friend class WhileLoopContinuation; - - void play_complex(const Ts &...x) override { + void play_complex(const Ts &...x) final { this->num_running_++; // Initial condition check if (!this->condition_->check(x...)) { @@ -392,12 +359,25 @@ template class WhileAction : public Action { return; } - if (this->num_running_ > 0) { - this->then_.play(x...); + // The condition may have stopped this automation + if (this->num_running_ == 0) + return; + // An empty body cannot resume the loop + if (this->then_.empty()) { + this->play_next_(x...); + return; } + this->then_.play(x...); } - void play(const Ts &...x) override { /* ignore - see play_complex */ + // Branch-end resume hook: loop again or continue + void play(const Ts &...x) override { + // The condition may stop this automation + if (this->num_running_ > 0 && this->condition_->check(x...) && this->num_running_ > 0) { + this->then_.play(x...); + } else { + this->play_next_(x...); + } } void stop() override { this->then_.stop(); } @@ -405,25 +385,13 @@ template class WhileAction : public Action { protected: Condition *condition_; ActionList then_; - WhileLoopContinuation loop_continuation_{this}; }; -// Implementation of WhileLoopContinuation::play -template void WhileLoopContinuation::play(const Ts &...x) { - if (this->parent_->num_running_ > 0 && this->parent_->condition_->check(x...)) { - // play again - this->parent_->then_.play(x...); - } else { - // condition false, play next - this->parent_->play_next_(x...); - } -} - // Forward declaration for RepeatLoopContinuation template class RepeatAction; /// Loop continuation for RepeatAction that increments iteration and repeats or continues. -/// Memory: 4-8 bytes (parent pointer) vs 40 bytes (LambdaAction with std::function). +/// Repeat keeps a continuation: its body takes an extra uint32_t, so it cannot be tagged. template class RepeatLoopContinuation : public Action { public: explicit RepeatLoopContinuation(RepeatAction *parent) : parent_(parent) {} @@ -438,11 +406,14 @@ template class RepeatAction : public Action { public: TEMPLATABLE_VALUE(uint32_t, count) - // Precondition: must be called at most once per instance (see IfAction::add_then). + // Precondition: use either add_then or add_then_action plus one finish_then, never both + // (see IfAction::add_then); appending the continuation twice forms a self-loop. void add_then(const std::initializer_list *> &actions) { this->then_.add_actions(actions); this->then_.add_action(&this->loop_continuation_); } + void add_then_action(Action *action) { this->then_.add_action(action); } + void finish_then() { this->then_.add_action(&this->loop_continuation_); } friend class RepeatLoopContinuation; @@ -511,7 +482,7 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, x...); // Do immediate check with fresh timestamp - don't call loop() synchronously! // Let the event loop call it to avoid reentrancy issues @@ -543,7 +514,13 @@ template class WaitUntilAction : public Action, public Co } protected: - using QueueItem = std::tuple, std::tuple>; + struct QueueItem { + QueueItem(uint32_t start, optional timeout, const Ts &...x) + : start(start), timeout(timeout), args(x...) {} + uint32_t start; + optional timeout; + std::tuple...> args; + }; // Helper: Process queue, triggering completed items and removing them // Returns true if queue still has pending items @@ -557,14 +534,14 @@ template class WaitUntilAction : public Action, public Co std::list pending; while (!queue.empty()) { auto it = queue.begin(); - auto start = std::get(*it); - auto timeout = std::get>(*it); + auto start = it->start; + auto timeout = it->timeout; // Check if timeout has expired auto expired = timeout && (now - start) >= *timeout; // Keep waiting if not expired and condition not met - if (!expired && !this->condition_->check_tuple(std::get>(*it))) { + if (!expired && !std::apply([this](auto &...args) { return this->condition_->check(args...); }, it->args)) { pending.splice(pending.end(), queue, it); continue; } @@ -575,7 +552,7 @@ template class WaitUntilAction : public Action, public Co std::list completed; completed.splice(completed.begin(), queue, it); uint8_t generation = this->stop_generation_; - this->play_next_tuple_(std::get>(completed.front())); + std::apply([this](auto &...args) { this->play_next_(args...); }, completed.front().args); if (generation != this->stop_generation_) { // stop() ran inside the chain - the items still held locally were cancelled pending.clear(); @@ -592,7 +569,7 @@ template class WaitUntilAction : public Action, public Co Condition *condition_; std::list var_queue_{}; - // Bumped by stop() so process_queue_() can detect a stop from inside play_next_tuple_() + // Bumped by stop() so process_queue_() can detect a stop from inside play_next_() uint8_t stop_generation_{0}; }; diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 41dd32ea66..edcd922aeb 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: @@ -292,14 +292,17 @@ void Component::status_set_warning(const LogString *message) { } void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } void Component::status_set_error(const LogString *message) { - if (!this->set_status_flag_(STATUS_LED_ERROR)) - return; - ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), - message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); - if (message != nullptr) { + if (this->set_error_flag_(message) && message != nullptr) { store_component_error_message(this, message); } } +bool Component::set_error_flag_(const LogString *message) { + if (!this->set_status_flag_(STATUS_LED_ERROR)) + return false; + ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), + message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); + return true; +} void Component::status_clear_warning_slow_path_() { this->component_state_ &= ~STATUS_LED_WARNING; // Clear the app-wide STATUS_LED_WARNING bit only if setup has finished @@ -329,6 +332,21 @@ void Component::status_momentary_error(const char *name, uint32_t length) { this->status_set_error(); this->set_timeout(name, length, [this]() { this->status_clear_error(); }); } +void Component::status_momentary_warning(uint32_t length) { + this->status_momentary_warning(static_cast(nullptr), length); +} +void Component::status_momentary_warning(const LogString *message, uint32_t length) { + this->status_set_warning(message); + this->set_timeout(InternalSchedulerID::STATUS_WARNING, length, [this]() { this->status_clear_warning(); }); +} +void Component::status_momentary_error(uint32_t length) { + this->status_momentary_error(static_cast(nullptr), length); +} +void Component::status_momentary_error(const LogString *message, uint32_t length) { + // Not stored: a cleared momentary error must not show up later as a FAILED reason + this->set_error_flag_(message); + this->set_timeout(InternalSchedulerID::STATUS_ERROR, length, [this]() { this->status_clear_error(); }); +} void Component::dump_config() {} // Function implementation of LOG_UPDATE_INTERVAL macro to reduce code size diff --git a/esphome/core/component.h b/esphome/core/component.h index ecaf863ecf..0e95f8d4e9 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -67,6 +67,8 @@ inline constexpr uint32_t SCHEDULER_DONT_RUN = 4294967295UL; /// with component-level NUMERIC_ID values, even if the uint32_t values overlap. enum class InternalSchedulerID : uint32_t { POLLING_UPDATE = 0, // PollingComponent interval + STATUS_WARNING = 1, // status_momentary_warning() without a name + STATUS_ERROR = 2, // status_momentary_error() without a name }; // Forward declaration @@ -298,24 +300,24 @@ class Component { this->status_clear_error_slow_path_(); } - /** Set warning status flag and automatically clear it after a timeout. - * - * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). - * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. - * This is NOT a message to display - use status_set_warning() with a message if logging is needed. - * @param length Duration in milliseconds before the warning is automatically cleared. - */ + // Remove before 2027.5.0 + ESPDEPRECATED("Use status_momentary_warning() without a name. Removed in 2027.5.0", "2026.11.0") void status_momentary_warning(const char *name, uint32_t length = 5000); - /** Set error status flag and automatically clear it after a timeout. - * - * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). - * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. - * This is NOT a message to display - use status_set_error() with a message if logging is needed. - * @param length Duration in milliseconds before the error is automatically cleared. - */ + /// Set warning status flag and clear it after `length` ms. A new call restarts the timeout. + /// `message` is logged in place of "unspecified" when the flag gets set. + void status_momentary_warning(uint32_t length = 5000); + void status_momentary_warning(const LogString *message, uint32_t length = 5000); + + // Remove before 2027.5.0 + ESPDEPRECATED("Use status_momentary_error() without a name. Removed in 2027.5.0", "2026.11.0") void status_momentary_error(const char *name, uint32_t length = 5000); + /// Set error status flag and clear it after `length` ms. A new call restarts the timeout. + /// `message` is logged in place of "unspecified"; unlike status_set_error() it is not stored. + void status_momentary_error(uint32_t length = 5000); + void status_momentary_error(const LogString *message, uint32_t length = 5000); + bool has_overridden_loop() const { return (this->component_state_ & COMPONENT_HAS_LOOP) != 0; } /** Get the integration where this component was declared as a LogString for logging. @@ -356,6 +358,8 @@ class Component { /// Note: Callers often use the return value to decide whether to log a warning/error, /// so once a flag is set, subsequent (potentially different) messages may be suppressed. bool set_status_flag_(uint8_t flag); + /// Set the error flag and log it; true when it was not set before. + bool set_error_flag_(const LogString *message); /** Set an interval function with a const char* name. Empty name means no cancelling possible. * 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 8a4eb0fc37..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) @@ -755,7 +839,7 @@ async def to_code(config: ConfigType) -> None: ) CORE.add_job(_add_platform_defines) - CORE.add_job(_add_controller_registry_define) + CORE.add_job(_add_controller_registry_dispatch) CORE.add_job(_add_looping_components) CORE.add_job(_add_automations, config) @@ -769,6 +853,7 @@ async def to_code(config: ConfigType) -> None: cg.add_build_flag("-Wno-unused-variable") cg.add_build_flag("-Wno-unused-but-set-variable") cg.add_build_flag("-Wno-sign-compare") + cg.add_build_flag("-Wno-unused-function") # C++20 deprecated ++/--, compound assignment, and chained assignment on # volatile lvalues; GCC warns via -Wvolatile, on by default at gnu++20. # C++23 (P2327R1) removed the deprecation for compound assignment, so the @@ -780,6 +865,10 @@ async def to_code(config: ConfigType) -> None: cg.add_cxx_build_flag("-Wno-volatile") if config[CONF_DEBUG_SCHEDULER]: cg.add_define("ESPHOME_DEBUG_SCHEDULER") + if config[CONF_SUSPEND_LOOP]: + cg.add_define("ESPHOME_SUSPEND_LOOP") + if CONF_LOOP_INTERVAL in config: + cg.add(cg.App.set_loop_interval(config[CONF_LOOP_INTERVAL])) if CORE.using_arduino: CORE.add_job(add_arduino_global_workaround) @@ -874,7 +963,7 @@ async def to_code(config: ConfigType) -> None: # Platform-specific source files for core -FILTER_SOURCE_FILES = filter_source_files_from_platform( +_filter_platform_sources = filter_source_files_from_platform( { "static_task.cpp": { PlatformFramework.ESP32_ARDUINO, @@ -927,3 +1016,11 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( # as they are only included when needed by the preprocessor } ) + +# util.cpp holds only the out-of-line MQTT connectivity helpers; without +# USE_MQTT the header provides inline stubs and the file must not compile. +_filter_define_sources = filter_source_files_from_defines({"util.cpp": "USE_MQTT"}) + + +def FILTER_SOURCE_FILES() -> list[str]: + return _filter_platform_sources() + _filter_define_sources() diff --git a/esphome/core/controller.h b/esphome/core/controller.h deleted file mode 100644 index 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 30c9dc27ed..c645428c7c 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -48,6 +48,7 @@ #define USE_BINARY_SENSOR_FILTER #define USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER #define USE_BLE_DEVICE_IRK +#define USE_BMI270_BMM150 #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE @@ -63,6 +64,7 @@ #define USE_DEEP_SLEEP_ON_WAKE #define USE_DEVICES #define USE_DISPLAY +#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1 #define USE_ENTITY_DEVICE_CLASS #define USE_ENTITY_ICON #define USE_ENTITY_UNIT_OF_MEASUREMENT @@ -72,11 +74,14 @@ #define USE_ESP32_HOSTED_HTTP_UPDATE #define USE_ESP_NOW_HOSTED #define USE_EVENT +#define USE_EZSP_PROXY_TAP #define USE_FAN +#define USE_FEEDBACK_COVER_OVERSHOOT #define USE_GPIO_BINARY_SENSOR_INTERRUPT #define USE_GPIO_SWITCH_INTERLOCK #define USE_GRAPH #define USE_GRAPHICAL_DISPLAY_MENU +#define USE_HOERMANN_HCP_IDENTITY #define USE_HOMEASSISTANT_TIME #define USE_HOMEASSISTANT_TIMEZONE #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT @@ -85,10 +90,15 @@ #define USE_IMPROV_BLE_STATE_CALLBACK #define USE_INFRARED #define USE_IR_RF +#define USE_IR_RF_TRANSMIT_COMPLETE #define USE_JSON +#define USE_JSON_ARENA #define USE_RADIO_FREQUENCY #define USE_LIGHT +#define USE_LIGHT_FLASH_TRANSITION_LENGTH #define USE_LIGHT_GAMMA_LUT +#define USE_LIGHT_RESUME_EFFECT +#define USE_LIGHT_TRANSITION_PUBLISH_INTERVAL #define USE_LOCK #define USE_LOGGER #define USE_LOGGER_LEVEL_LISTENERS @@ -103,6 +113,8 @@ #define USE_LVGL_CANVAS #define USE_LVGL_CHART #define USE_LVGL_CHECKBOX +#define USE_LVGL_COLOR_PICKER +#define USE_LVGL_COLOR_PICKER_WHEEL #define USE_LVGL_DROPDOWN #define USE_LVGL_FONT #define USE_LVGL_GRADIENT @@ -131,12 +143,19 @@ #define USE_LVGL_TOUCHSCREEN #define USE_MDNS #define USE_MDNS_STORE_SERVICES +#define I2C_PORT_FREQUENCY_COUNT 1 #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 #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 @@ -152,6 +171,8 @@ #define USE_REMOTE_PROTOCOL_DYSON #define USE_REMOTE_PROTOCOL_GOBOX #define USE_REMOTE_PROTOCOL_HAIER +#define USE_REMOTE_PROTOCOL_HOB2HOOD +#define USE_REMOTE_PROTOCOL_IRA211 #define USE_REMOTE_PROTOCOL_JVC #define USE_REMOTE_PROTOCOL_KEELOQ #define USE_REMOTE_PROTOCOL_LG @@ -160,22 +181,30 @@ #define USE_REMOTE_PROTOCOL_MIRAGE #define USE_REMOTE_PROTOCOL_NEC #define USE_REMOTE_PROTOCOL_NEXA +#define USE_REMOTE_PROTOCOL_NEXUS +#define USE_REMOTE_PROTOCOL_ONKYORI #define USE_REMOTE_PROTOCOL_PANASONIC +#define USE_REMOTE_PROTOCOL_PD_PIONEER #define USE_REMOTE_PROTOCOL_PIONEER +#define USE_REMOTE_PROTOCOL_PIONEER_WYT #define USE_REMOTE_PROTOCOL_PRONTO #define USE_REMOTE_PROTOCOL_RAW #define USE_REMOTE_PROTOCOL_RC5 +#define USE_REMOTE_PROTOCOL_RC5X #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_SOMFY_RTS #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 2 #define SERIAL_PROXY_COUNT 2 #define SNTP_SERVER_COUNT 3 +#define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK @@ -228,14 +257,20 @@ #define USE_TEXT_SENSOR_FILTER #define USE_TIME #define USE_TIME_TRIGGERS +#define USE_TMP102_BINARY_SENSOR +#define USE_TMP102_CONFIGURE #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_UART_WAKE_LOOP_ON_RX +#define USE_UFM01_CLEAR_ACCUMULATED_FLOW_ACTION +#define USE_UFM01_METER_ID +#define USE_UFM01_SOFTWARE_VERSION #define USE_UPDATE #define USE_UPTIME_TIMESTAMP #define USE_VALVE #define USE_WATER_HEATER #define USE_WATER_HEATER_VISUAL_OVERRIDES +#define USE_WHIRLPOOL_HISENSE_ARG #define USE_ZWAVE_PROXY // Feature flags which do not work for zephyr @@ -243,6 +278,7 @@ #define AUDIO_FILE_MAX_FILES 4 #define USE_AUDIO_DAC #define USE_AUDIO_FLAC_SUPPORT +#define USE_AUDIO_HTTP_CA_CERTIFICATE #define USE_AUDIO_MP3_SUPPORT #define USE_AUDIO_OPUS_SUPPORT #define USE_AUDIO_WAV_SUPPORT @@ -254,7 +290,15 @@ #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_MULTIPLE_INPUTS +#define USE_API_WIZARD_STANDALONE_INPUTS #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES @@ -263,12 +307,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_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 @@ -282,6 +329,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 @@ -339,7 +387,6 @@ #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 #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 @@ -376,6 +423,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 @@ -385,9 +433,11 @@ #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 +#define ECOCOMFORT2_CHILD_COUNT 1 #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 @@ -403,6 +453,10 @@ #define USE_SENDSPIN_VISUALIZER #define USE_SENDSPIN_PORT 8928 // NOLINT #define USE_SOCKET_IMPL_BSD_SOCKETS +#define USE_SOCKET_IPV4_ALLOW +#define USE_SOCKET_IPV4_RESOLVE +#define USE_SOCKET_TCP_CLIENT_LINK +#define USE_SOCKET_TCP_LISTENER #define USE_LWIP_FAST_SELECT #define USE_SPEAKER @@ -413,6 +467,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_OTA #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_WEBSERVER_GZIP @@ -423,6 +478,8 @@ #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT #define USE_WIFI_FAST_CONNECT_IN_FLASH +#define USE_WIFI_LEASE_CACHE +#define USE_WIFI_LEASE_CACHE_IN_FLASH #define USE_WIFI_PHY_MODE #define USE_WIFI_IP_STATE_LISTENERS #define USE_WIFI_SCAN_RESULTS_LISTENERS @@ -439,6 +496,16 @@ #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 +#define USE_GPIO_HOLD +// 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) @@ -461,23 +528,31 @@ #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 #define USE_MICRO_WAKE_WORD +#define USE_MICRO_WAKE_WORD_INTERNAL_RAM #define USE_MICRO_WAKE_WORD_VAD #if defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) #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 @@ -501,6 +576,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) @@ -529,6 +605,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #endif @@ -536,7 +613,7 @@ // rp2/__init__.py codegen also defines USE_RP2040 as a back-compat alias // for external custom components that may still test for it. #ifdef USE_RP2 -#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 0) +#define USE_ARDUINO_VERSION_CODE VERSION_CODE(6, 1, 1) #define USE_RP2_CRASH_HANDLER #define USE_HTTP_REQUEST_RESPONSE #define USE_I2C @@ -574,10 +651,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 @@ -586,6 +665,7 @@ #define USE_WEBSERVER #define USE_WEBSERVER_AUTH #define USE_WEBSERVER_AUTH_DIGEST +#define USE_WEBSERVER_CAPTIVE #define USE_WEBSERVER_PORT 80 // NOLINT #define USE_ESPHOME_TASK_LOG_BUFFER #define ESPHOME_TASK_LOG_BUFFER_SIZE 768 @@ -603,6 +683,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 @@ -613,6 +694,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 @@ -628,7 +710,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 @@ -657,3 +738,12 @@ #define ESPHOME_ENTITY_VALVE_COUNT 1 #define ESPHOME_ENTITY_WATER_HEATER_COUNT 1 #define ESPHOME_MAX_USB_CDC_INSTANCES 1 + +// Keep clang-tidy happy. + +// The real value comes from lvgl/gradient.py, which works it out from the configuration. The +// largest it uses is set here so that static analysis sees every gradient in the component, +// but only where a generated lv_conf.h has not already given it a value. +#ifndef LV_GRADIENT_MAX_STOPS +#define LV_GRADIENT_MAX_STOPS 7 +#endif 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/event_pool.h b/esphome/core/event_pool.h index b53b8064a3..77b47228a9 100644 --- a/esphome/core/event_pool.h +++ b/esphome/core/event_pool.h @@ -1,6 +1,7 @@ #pragma once -#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) +#if defined(USE_ESP32) || defined(USE_ZEPHYR) || defined(USE_LIBRETINY) || defined(USE_RP2) || defined(USE_HOST) || \ + defined(USE_ESP8266) #include #include 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/gpio.h b/esphome/core/gpio.h index 43db3b7c0c..8ed0d70f6b 100644 --- a/esphome/core/gpio.h +++ b/esphome/core/gpio.h @@ -1,141 +1,44 @@ #pragma once -#include -#include +#include "esphome/core/gpio_pin.h" -#include "esphome/core/helpers.h" -#include "esphome/core/log.h" +// Each platform has exactly one internal pin class, so InternalGPIOPin is an alias for +// it instead of an abstract base class: calls through the alias are direct and can be inlined. +#if defined(USE_ESP32) +#include "esphome/components/esp32/gpio.h" +namespace esphome { +using InternalGPIOPin = esp32::ESP32InternalGPIOPin; +} +#elif defined(USE_ESP8266) +#include "esphome/components/esp8266/gpio.h" +namespace esphome { +using InternalGPIOPin = esp8266::ESP8266GPIOPin; +} +#elif defined(USE_LIBRETINY) +#include "esphome/components/libretiny/gpio_arduino.h" +namespace esphome { +using InternalGPIOPin = libretiny::ArduinoInternalGPIOPin; +} +#elif defined(USE_RP2) +#include "esphome/components/rp2/gpio.h" +namespace esphome { +using InternalGPIOPin = rp2::RP2GPIOPin; +} +#elif defined(USE_HOST) +#include "esphome/components/host/gpio.h" +namespace esphome { +using InternalGPIOPin = host::HostGPIOPin; +} +#elif defined(USE_ZEPHYR) +#include "esphome/components/zephyr/gpio.h" +namespace esphome { +using InternalGPIOPin = zephyr::ZephyrGPIOPin; +} +#else +#error "gpio.h: no internal pin class for this platform" +#endif namespace esphome { - -/// Maximum buffer size for dump_summary output -inline constexpr size_t GPIO_SUMMARY_MAX_LEN = 48; - -#ifdef USE_ESP8266 -#define LOG_PIN(prefix, pin) log_pin(TAG, F(prefix), pin) -#else -#define LOG_PIN(prefix, pin) log_pin(TAG, prefix, pin) -#endif - -// put GPIO flags in a namespace to not pollute esphome namespace -namespace gpio { - -enum Flags : uint8_t { - // Can't name these just INPUT because of Arduino defines :( - FLAG_NONE = 0x00, - FLAG_INPUT = 0x01, - FLAG_OUTPUT = 0x02, - FLAG_OPEN_DRAIN = 0x04, - FLAG_PULLUP = 0x08, - FLAG_PULLDOWN = 0x10, -}; - -class FlagsHelper { - public: - constexpr FlagsHelper(Flags val) : val_(val) {} - constexpr operator Flags() const { return val_; } - - protected: - Flags val_; -}; -constexpr FlagsHelper operator&(Flags lhs, Flags rhs) { - return static_cast(static_cast(lhs) & static_cast(rhs)); -} -constexpr FlagsHelper operator|(Flags lhs, Flags rhs) { - return static_cast(static_cast(lhs) | static_cast(rhs)); -} - -enum InterruptType : uint8_t { - INTERRUPT_RISING_EDGE = 1, - INTERRUPT_FALLING_EDGE = 2, - INTERRUPT_ANY_EDGE = 3, - INTERRUPT_LOW_LEVEL = 4, - INTERRUPT_HIGH_LEVEL = 5, -}; - -} // namespace gpio - -class GPIOPin { - public: - virtual void setup() = 0; - - virtual void pin_mode(gpio::Flags flags) = 0; - - /** - * @brief Retrieve GPIO pin flags. - * - * @return The GPIO flags describing the pin mode and properties. - */ - virtual gpio::Flags get_flags() const = 0; - - virtual bool digital_read() = 0; - - virtual void digital_write(bool value) = 0; - - /// Write a summary of this pin to the provided buffer. - /// @param buffer The buffer to write to - /// @param len The size of the buffer (must be > 0) - /// @return The number of characters that would be written (excluding null terminator), - /// which may exceed len-1 if truncation occurred (snprintf semantics) - virtual size_t dump_summary(char *buffer, size_t len) const; - - virtual bool is_internal() { return false; } -}; - -/// Copy of GPIOPin that is safe to use from ISRs (with no virtual functions) -class ISRInternalGPIOPin { - public: - ISRInternalGPIOPin() = default; - ISRInternalGPIOPin(void *arg) : arg_(arg) {} - bool digital_read(); - void digital_write(bool value); - void clear_interrupt(); - void pin_mode(gpio::Flags flags); - - protected: - void *arg_{nullptr}; -}; - -class InternalGPIOPin : public GPIOPin { - public: - template void attach_interrupt(void (*func)(T *), T *arg, gpio::InterruptType type) const { - this->attach_interrupt(reinterpret_cast(func), arg, type); - } - - virtual void detach_interrupt() const = 0; - - virtual ISRInternalGPIOPin to_isr() const = 0; - - virtual uint8_t get_pin() const = 0; - - bool is_internal() override { return true; } - - virtual bool is_inverted() const = 0; - - protected: - virtual void attach_interrupt(void (*func)(void *), void *arg, gpio::InterruptType type) const = 0; -}; - -// Inline default implementation for GPIOPin::dump_summary. -// Writes an empty summary; subclasses override to provide pin details. -inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { - if (len > 0) - buffer[0] = '\0'; - return 0; -} - -// Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp -inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { - char buffer[GPIO_SUMMARY_MAX_LEN]; - size_t len = pin->dump_summary(buffer, sizeof(buffer)); - len = std::min(len, sizeof(buffer) - 1); - esp_log_printf_(ESPHOME_LOG_LEVEL_CONFIG, tag, __LINE__, "%s%.*s", prefix, (int) len, buffer); -} - -// log_pin function declarations - implementation in gpio.cpp -#ifdef USE_ESP8266 -void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin); -#else -void log_pin(const char *tag, const char *prefix, GPIOPin *pin); -#endif - +static_assert(InternalGPIOPinContract, + "The platform's internal pin class is missing part of the InternalGPIOPin surface " + "(esphome/core/gpio_pin.h)"); } // namespace esphome diff --git a/esphome/core/gpio_pin.h b/esphome/core/gpio_pin.h new file mode 100644 index 0000000000..6c9acfa8d6 --- /dev/null +++ b/esphome/core/gpio_pin.h @@ -0,0 +1,147 @@ +#pragma once +#include +#include +#include +#include + +#include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome { + +/// Maximum buffer size for dump_summary output +inline constexpr size_t GPIO_SUMMARY_MAX_LEN = 48; + +#ifdef USE_ESP8266 +#define LOG_PIN(prefix, pin) log_pin(TAG, F(prefix), pin) +#else +#define LOG_PIN(prefix, pin) log_pin(TAG, prefix, pin) +#endif + +// put GPIO flags in a namespace to not pollute esphome namespace +namespace gpio { + +enum Flags : uint8_t { + // Can't name these just INPUT because of Arduino defines :( + FLAG_NONE = 0x00, + FLAG_INPUT = 0x01, + FLAG_OUTPUT = 0x02, + FLAG_OPEN_DRAIN = 0x04, + FLAG_PULLUP = 0x08, + FLAG_PULLDOWN = 0x10, + FLAG_HOLD = 0x20, +}; + +class FlagsHelper { + public: + constexpr FlagsHelper(Flags val) : val_(val) {} + constexpr operator Flags() const { return val_; } + + protected: + Flags val_; +}; +constexpr FlagsHelper operator&(Flags lhs, Flags rhs) { + return static_cast(static_cast(lhs) & static_cast(rhs)); +} +constexpr FlagsHelper operator|(Flags lhs, Flags rhs) { + return static_cast(static_cast(lhs) | static_cast(rhs)); +} + +enum InterruptType : uint8_t { + INTERRUPT_RISING_EDGE = 1, + INTERRUPT_FALLING_EDGE = 2, + INTERRUPT_ANY_EDGE = 3, + INTERRUPT_LOW_LEVEL = 4, + INTERRUPT_HIGH_LEVEL = 5, +}; + +} // namespace gpio + +class GPIOPin { + public: + virtual void setup() = 0; + + virtual void pin_mode(gpio::Flags flags) = 0; + + /** + * @brief Retrieve GPIO pin flags. + * + * @return The GPIO flags describing the pin mode and properties. + */ + virtual gpio::Flags get_flags() const = 0; + + virtual bool digital_read() = 0; + + virtual void digital_write(bool value) = 0; + + /// Write a summary of this pin to the provided buffer. + /// @param buffer The buffer to write to + /// @param len The size of the buffer (must be > 0) + /// @return The number of characters that would be written (excluding null terminator), + /// which may exceed len-1 if truncation occurred (snprintf semantics) + virtual size_t dump_summary(char *buffer, size_t len) const; + + /// Only the platform pin class behind the InternalGPIOPin alias may return true: + /// callers static_cast to InternalGPIOPin on it. + virtual bool is_internal() { return false; } +#ifdef USE_GPIO_HOLD + bool get_hold() const { return this->get_flags() & gpio::FLAG_HOLD; } + virtual bool is_held() const { return false; } +#else + bool get_hold() const { return false; } + bool is_held() const { return false; } +#endif +}; + +/// Copy of GPIOPin that is safe to use from ISRs (with no virtual functions) +class ISRInternalGPIOPin { + public: + ISRInternalGPIOPin() = default; + ISRInternalGPIOPin(void *arg) : arg_(arg) {} + bool digital_read(); + void digital_write(bool value); + void clear_interrupt(); + void pin_mode(gpio::Flags flags); + + protected: + void *arg_{nullptr}; +}; + +// What callers of the InternalGPIOPin alias (bound in esphome/core/gpio.h) rely on beyond +// GPIOPin. The class must be final so calls to the GPIOPin virtuals through the alias are direct. +// int is arbitrary: any T * exercises the public attach_interrupt template. +template +concept InternalGPIOPinContract = std::derived_from && std::is_final_v && + requires(const T pin, void (*func)(int *), int *arg, gpio::InterruptType type) { + { pin.get_pin() } -> std::same_as; + { pin.is_inverted() } -> std::same_as; + { pin.to_isr() } -> std::same_as; + { pin.detach_interrupt() } -> std::same_as; + { pin.attach_interrupt(func, arg, type) } -> std::same_as; +}; + +// Inline default implementation for GPIOPin::dump_summary. +// Writes an empty summary; subclasses override to provide pin details. +inline size_t GPIOPin::dump_summary(char *buffer, size_t len) const { + if (len > 0) + buffer[0] = '\0'; + return 0; +} + +// Inline helper for log_pin - allows compiler to inline into log_pin in gpio.cpp +inline void log_pin_with_prefix(const char *tag, const char *prefix, GPIOPin *pin) { + char buffer[GPIO_SUMMARY_MAX_LEN]; + size_t len = pin->dump_summary(buffer, sizeof(buffer)); + len = std::min(len, sizeof(buffer) - 1); + esp_log_printf_(ESPHOME_LOG_LEVEL_CONFIG, tag, __LINE__, "%s%.*s", prefix, (int) len, buffer); +} + +// log_pin function declarations - implementation in gpio.cpp +#ifdef USE_ESP8266 +void log_pin(const char *tag, const __FlashStringHelper *prefix, GPIOPin *pin); +#else +void log_pin(const char *tag, const char *prefix, GPIOPin *pin); +#endif + +} // namespace esphome diff --git a/esphome/core/hal.h b/esphome/core/hal.h index 4c5a19c6d1..dd3618f623 100644 --- a/esphome/core/hal.h +++ b/esphome/core/hal.h @@ -4,9 +4,17 @@ #include #include "gpio.h" #include "esphome/core/defines.h" + #include "esphome/core/time_64.h" #include "esphome/core/time_conversion.h" +/// Address tables in ESP8266 flash must be constant-initialized: a runtime initializer would write to flash. +#if defined(USE_ESP8266) && defined(__GNUC__) && !defined(__clang__) +#define ESPHOME_FLASH_CONSTINIT constinit +#else +#define ESPHOME_FLASH_CONSTINIT +#endif + // Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(), // inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem // helpers) live next to each platform component as components//hal.h diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 313daacbd0..906f9fb12a 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -29,7 +28,7 @@ std::string progmem_string(ProgmemStr str) { } #endif -static const char *const TAG = "helpers"; +ESPHOME_LOG_TAG(TAG, "helpers"); __attribute__((noinline, cold)) void *callback_manager_grow(void *data, uint16_t size, uint16_t &capacity, size_t elem_size) { @@ -262,7 +261,6 @@ bool str_contains_ignore_case_p(const char *haystack, PGM_P needle) { } #endif // USE_ESP8266 -// str_truncate, str_until, str_lower_case, str_upper_case, str_snake_case moved to alloc_helpers.cpp char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { if (buffer_size == 0) { return buffer; @@ -275,8 +273,6 @@ char *str_sanitize_to(char *buffer, size_t buffer_size, const char *str) { return buffer; } -// str_sanitize, str_snprintf, str_sprintf moved to alloc_helpers.cpp - size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len) { size_t total_len = name_len + 1 + suffix_len; @@ -297,6 +293,26 @@ size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *na return total_len; } +size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len) { + if (buffer_size == 0) { + return 0; + } + const size_t max_len = buffer_size - 1; + if (name_len <= max_len) { + memcpy(buffer, name, name_len); + buffer[name_len] = '\0'; + return name_len; + } + if (keep_suffix_len > max_len) { + keep_suffix_len = max_len; + } + const size_t head_len = max_len - keep_suffix_len; + memcpy(buffer, name, head_len); + memcpy(buffer + head_len, name + name_len - keep_suffix_len, keep_suffix_len); + buffer[max_len] = '\0'; + return max_len; +} + // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { @@ -310,8 +326,6 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { return chars; } -// format_mac_address_pretty moved to alloc_helpers.cpp - // Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase. // When separator is set, it is written unconditionally after each byte and the last // one is overwritten with '\0', eliminating the per-byte `i < length - 1` check. @@ -429,8 +443,6 @@ const char *json_escape_into_buffer(std::span buf, StringRef value, bool s return buf.data(); } -// format_hex (std::string returning overloads) moved to alloc_helpers.cpp - char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator) { return format_hex_internal(buffer, buffer_size, data, length, separator, 'A'); } @@ -466,8 +478,6 @@ char *format_hex_pretty_to(char *buffer, size_t buffer_size, const uint16_t *dat return buffer; } -// format_hex_pretty (all std::string returning overloads) moved to alloc_helpers.cpp - char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { if (buffer_size == 0) { return buffer; @@ -489,8 +499,6 @@ char *format_bin_to(char *buffer, size_t buffer_size, const uint8_t *data, size_ return buffer; } -// format_bin moved to alloc_helpers.cpp - ParseOnOffState parse_on_off(const char *str, const char *on, const char *off) { if (on == nullptr && ESPHOME_strcasecmp_P(str, ESPHOME_PSTR("on")) == 0) return PARSE_ON; @@ -538,8 +546,6 @@ static inline void normalize_accuracy_decimals(float &value, int8_t &accuracy_de } } -// value_accuracy_to_string moved to alloc_helpers.cpp - // Fast float-to-string for accuracy_decimals 0-3 (covers virtually all sensor usage). // Avoids snprintf("%.*f") which pulls in heavy float formatting machinery. // Caller must guarantee value is finite and |value| * mult fits in uint32_t. @@ -621,15 +627,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') @@ -641,75 +643,36 @@ 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; } -// base64_decode (vector-returning overload) moved to alloc_helpers.cpp - /// Decode base64/base64url string directly into vector of little-endian int32 values /// @param base64 Base64 or base64url encoded string (both +/ and -_ accepted) /// @param out Output vector (cleared and filled with decoded int32 values) @@ -830,8 +793,6 @@ void HighFrequencyLoopRequester::stop() { this->started_ = false; } -// get_mac_address, get_mac_address_pretty moved to alloc_helpers.cpp - void get_mac_address_into_buffer(std::span buf) { uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index cfc92932a9..bcb03b010f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -26,12 +27,6 @@ #include "esphome/core/optional.h" #include "esphome/core/time_conversion.h" -// Backward compatibility re-export of heap-allocating helpers. -// These functions have moved to alloc_helpers.h. External components should -// update their includes to use #include "esphome/core/alloc_helpers.h" directly. -// This re-export will be removed in 2026.11.0. -#include "esphome/core/alloc_helpers.h" - #ifdef USE_ESP8266 #include #include @@ -129,23 +124,82 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); /// @name Container utilities ///@{ -/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory) -/// Avoids copying data from flash to RAM by keeping a pointer to the flash data. -/// Similar to std::span but with minimal overhead for embedded systems. - -template class ConstVector { +/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory). +/// Iterators are raw pointers like FixedVector. With Owning = true it can also hold a heap copy it +/// owns (see the specialization below); the default view never frees and has no extra cost. +template class ConstVector { public: + using value_type = T; + + constexpr ConstVector() = default; constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} - const constexpr T &operator[](size_t i) const { return data_[i]; } - constexpr size_t size() const { return size_; } - constexpr bool empty() const { return size_ == 0; } + const T *begin() const { return this->data_; } + const T *end() const { return this->data_ + this->size_; } + const T *data() const { return this->data_; } + constexpr size_t size() const { return this->size_; } + constexpr bool empty() const { return this->size_ == 0; } + const constexpr T &operator[](size_t i) const { return this->data_[i]; } + const T &at(size_t i) const { return this->data_[i]; } protected: - const T *data_; - size_t size_; + const T *data_{nullptr}; + size_t size_{0}; }; +/// Owning variant: a codegen table that outlives it, or a heap copy of a runtime list it owns. +/// Ownership is the top bit of the size; it is not copyable, so a copy can never outlive the owner. +/// Elements must be whole words so ESP8266 can read a codegen table from flash. +template class ConstVector { + static_assert(std::is_trivially_copyable_v && sizeof(T) % sizeof(uint32_t) == 0, + "ConstVector elements must be whole words so ESP8266 can read them from flash"); + + public: + using value_type = T; + + constexpr ConstVector() = default; + constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} + ConstVector(const ConstVector &) = delete; + ConstVector &operator=(const ConstVector &) = delete; + ~ConstVector() { this->release_(); } + + const T *begin() const { return this->data_; } + const T *end() const { return this->data_ + this->size(); } + const T *data() const { return this->data_; } + size_t size() const { return this->size_ & ~OWNED_BIT; } + bool empty() const { return this->size() == 0; } + const T &operator[](size_t index) const { return this->data_[index]; } + const T &at(size_t index) const { return this->data_[index]; } + + /// Codegen only: call before any runtime copy; it does not free a previous owned copy + /// (generated setup() runs before any lambda or automation can call set_options). + void assign_static(const T *data, size_t size) { + this->data_ = data; + this->size_ = size; + } + /// Copies the list into a heap array this owns, freeing a previous owned copy. + void assign_copy(const T *data, size_t size) { + auto *table = new T[size]; // NOLINT(cppcoreguidelines-owning-memory) + std::copy(data, data + size, table); + this->release_(); + this->data_ = table; + this->size_ = size | OWNED_BIT; + } + + protected: + static constexpr size_t OWNED_BIT = size_t{1} << (sizeof(size_t) * 8 - 1); + + void release_() { + if (this->size_ & OWNED_BIT) + delete[] this->data_; // NOLINT(cppcoreguidelines-owning-memory) + } + + const T *data_{nullptr}; + size_t size_{0}; // top bit set when data_ is an owned heap copy +}; +static_assert(sizeof(ConstVector) == 2 * sizeof(void *), + "ConstVector must stay a pointer and a size"); + /// Small buffer optimization - stores data inline when small, heap-allocates for large data /// This avoids heap fragmentation for common small allocations while supporting arbitrary sizes. /// Memory management is encapsulated - callers just use set() and data(). @@ -276,6 +330,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) { @@ -823,6 +880,13 @@ template T remap(U value, U min, U max, T min_out, T max return (value - min) * (max_out - min_out) / (max - min) + min_out; } +/// Add up the bytes of \p data with size \p len, truncated to 8 bits: the checksum many serial and IR +/// protocols use. Prefer this over a bare std::accumulate, whose result type follows the seed, so a plain 0 +/// sums in int and compares wrong against a checksum byte. +constexpr uint8_t sum_bytes(const uint8_t *data, size_t len, uint8_t sum = 0) { + return std::accumulate(data, data + len, sum); +} + /// Calculate a CRC-8 checksum of \p data with size \p len. uint8_t crc8(const uint8_t *data, uint8_t len, uint8_t crc = 0x00, uint8_t poly = 0x8C, bool msb_first = false); @@ -1043,13 +1107,8 @@ inline bool str_contains_ignore_case(const char *haystack, const char *needle) { } #endif // USE_ESP8266 -// str_truncate moved to alloc_helpers.h - remove this include before 2026.11.0 - -// str_until, str_lower_case, str_upper_case moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Convert a single char to snake_case: lowercase and space to underscore. constexpr char to_snake_case_char(char c) { return (c == ' ') ? '_' : (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; } -// str_snake_case moved to alloc_helpers.h - remove this comment before 2026.11.0 /// Sanitize a single char: keep alphanumerics, dashes, underscores; replace others with underscore. constexpr char to_sanitized_char(char c) { @@ -1072,8 +1131,6 @@ template inline char *str_sanitize_to(char (&buffer)[N], const char *s return str_sanitize_to(buffer, N, str); } -// str_sanitize moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Calculate FNV-1 hash of a string while applying snake_case + sanitize transformations. /// This computes object_id hashes directly from names without creating an intermediate buffer. /// IMPORTANT: Must match Python fnv1_hash_object_id() in esphome/helpers.py. @@ -1088,8 +1145,6 @@ inline uint32_t fnv1_hash_object_id(const char *str, size_t len) { return hash; } -// str_snprintf, str_sprintf moved to alloc_helpers.h - remove this comment before 2026.11.0 - #ifdef USE_ESP8266 // ESP8266: Use vsnprintf_P to keep format strings in flash (PROGMEM) // Format strings must be wrapped with PSTR() macro @@ -1201,6 +1256,16 @@ static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; size_t make_name_with_suffix_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, char sep, const char *suffix_ptr, size_t suffix_len); +/// Length of the suffix name_add_mac_suffix appends: "-" plus 6 characters of the MAC address +static constexpr size_t MAC_ADDRESS_SUFFIX_WITH_SEPARATOR_LEN = 7; + +/// Copy a name into a buffer, cutting it when it does not fit. +/// A name that is cut keeps its last keep_suffix_len characters (for example a MAC suffix) and loses +/// the middle instead; pass 0 to cut the end. +/// @param buffer Output buffer; holds at most buffer_size - 1 characters plus the null terminator +/// @return Length written (excluding null terminator) +size_t truncate_name_to(char *buffer, size_t buffer_size, const char *name, size_t name_len, size_t keep_suffix_len); + ///@} /// @name Parsing & formatting @@ -1543,32 +1608,6 @@ inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { format_hex_to(output, MAC_ADDRESS_BUFFER_SIZE, mac, MAC_ADDRESS_SIZE); } -// format_mac_address_pretty, format_hex (all overloads) moved to alloc_helpers.h -// Remove this comment and the template overloads below before 2026.11.0 - -/// Format an unsigned integer in lowercased hex, starting with the most significant byte. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -template::value, int> = 0> std::string format_hex(T val) { - val = convert_big_endian(val); - return format_hex(reinterpret_cast(&val), sizeof(T)); -} -/// Format the std::array \p data in lowercased hex. -/// @warning Allocates heap memory. Use format_hex_to() with a stack buffer instead. -template std::string format_hex(const std::array &data) { - return format_hex(data.data(), data.size()); -} - -// format_hex_pretty (all overloads) moved to alloc_helpers.h -// Remove this comment and the template overload below before 2026.11.0 - -/// Format an unsigned integer in pretty-printed, human-readable hex format. -/// @warning Allocates heap memory. Use format_hex_pretty_to() with a stack buffer instead. -template::value, int> = 0> -std::string format_hex_pretty(T val, char separator = '.', bool show_length = true) { - val = convert_big_endian(val); - return format_hex_pretty(reinterpret_cast(&val), sizeof(T), separator, show_length); -} - /// Calculate buffer size needed for format_bin_to: "01234567...\0" = bytes * 8 + 1 constexpr size_t format_bin_size(size_t byte_count) { return byte_count * 8 + 1; } @@ -1622,15 +1661,6 @@ inline char *format_bin_to(char (&buffer)[N], T val) { return format_bin_to(buffer, reinterpret_cast(&val), sizeof(T)); } -// format_bin moved to alloc_helpers.h - remove this comment and template overload before 2026.11.0 - -/// Format an unsigned integer in binary, starting with the most significant byte. -/// @warning Allocates heap memory. Use format_bin_to() with a stack buffer instead. -template::value, int> = 0> std::string format_bin(T val) { - val = convert_big_endian(val); - return format_bin(reinterpret_cast(&val), sizeof(T)); -} - /// Return values for parse_on_off(). enum ParseOnOffState : uint8_t { PARSE_NONE = 0, @@ -1641,8 +1671,6 @@ enum ParseOnOffState : uint8_t { /// Parse a string that contains either on, off or toggle. ParseOnOffState parse_on_off(const char *str, const char *on = nullptr, const char *off = nullptr); -// value_accuracy_to_string moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Maximum buffer size for value_accuracy formatting (float ~15 chars + space + UOM ~40 chars + null) static constexpr size_t VALUE_ACCURACY_MAX_LEN = 64; @@ -1655,8 +1683,6 @@ size_t value_accuracy_with_uom_to_buf(std::span bu /// Derive accuracy in decimals from an increment step. int8_t step_to_accuracy_decimals(float step); -// base64_encode (both overloads), base64_decode (vector overload) moved to alloc_helpers.h -// Remove this comment before 2026.11.0 size_t base64_decode(std::string const &encoded_string, uint8_t *buf, size_t buf_len); size_t base64_decode(const uint8_t *encoded_data, size_t encoded_len, uint8_t *buf, size_t buf_len); @@ -2066,6 +2092,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 @@ -2089,8 +2141,6 @@ class HighFrequencyLoopRequester { /// Get the device MAC address as raw bytes, written into the provided byte array (6 bytes). void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) -// get_mac_address, get_mac_address_pretty moved to alloc_helpers.h - remove this comment before 2026.11.0 - /// Get the device MAC address into the given buffer, in lowercase hex notation. /// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (12 digits for hexadecimal representation followed by null /// terminator). @@ -2174,7 +2224,7 @@ template class RAMAllocator { T *ptr = nullptr; #ifdef USE_ESP32 const auto caps = this->get_caps_(); - ptr = static_cast(heap_caps_malloc_prefer(size, 2, caps[0], caps[1])); + ptr = static_cast(heap_caps_malloc_prefer(size, caps.num, caps.primary, caps.fallback)); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(malloc(size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2189,7 +2239,7 @@ template class RAMAllocator { T *ptr = nullptr; #ifdef USE_ESP32 const auto caps = this->get_caps_(); - ptr = static_cast(heap_caps_realloc_prefer(p, size, 2, caps[0], caps[1])); + ptr = static_cast(heap_caps_realloc_prefer(p, size, caps.num, caps.primary, caps.fallback)); #else // Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported ptr = static_cast(realloc(p, size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc) @@ -2261,12 +2311,21 @@ template class RAMAllocator { private: #ifdef USE_ESP32 - /// Returns {primary_caps, fallback_caps} for heap_caps_*_prefer based on the configured flags. + struct Caps { + uint32_t primary; + uint32_t fallback; + uint8_t num; // distinct capability sets to hand to heap_caps_*_prefer + constexpr Caps(uint32_t primary_caps, uint32_t fallback_caps) + : primary(primary_caps), fallback(fallback_caps), num(primary_caps == fallback_caps ? 1 : 2) {} + }; + + /// Returns the capability sets for heap_caps_*_prefer based on the configured flags. /// PREFER_INTERNAL implies both regions are enabled (enforced by the constructor), so when it is set /// the primary is internal and the fallback is external. Otherwise the primary is whichever region - /// is enabled (external preferred when both are enabled), and the fallback is the other region (or - /// the same region when only one is enabled, making the second attempt a no-op). - std::array get_caps_() const { + /// is enabled (external preferred when both are enabled), and the fallback is the other region (or the + /// same region when only one is enabled). With a single region enabled num is 1, so the duplicate + /// fallback is never searched. + constexpr Caps get_caps_() const { constexpr uint32_t external_caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT; constexpr uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; if (this->flags_ & PREFER_INTERNAL) { diff --git a/esphome/core/log.h b/esphome/core/log.h index 362ed83b91..145ba3a6f8 100644 --- a/esphome/core/log.h +++ b/esphome/core/log.h @@ -190,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/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 2042c43804..8e68594a03 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -99,10 +99,10 @@ // ESP32-C3/C6/H2) cross-core visibility is not an issue. // // FreeRTOS task notification value: -// Written by TCP/IP thread (xTaskNotifyGive in callback) and background tasks -// (xTaskNotifyGive in wake_main_loop). Read by main loop (ulTaskNotifyTake). +// Written by TCP/IP thread (esphome_main_task_notify in callback) and background tasks +// (esphome_main_task_notify in wake_main_loop). Read by main loop (esphome_main_task_wait). // Safe: FreeRTOS notification APIs are thread-safe by design (use internal -// critical sections). Multiple concurrent xTaskNotifyGive calls are safe — +// critical sections). Multiple concurrent notify calls are safe — // the notification count simply increments. // USE_LWIP_FAST_SELECT is set via -D build flag (not cg.add_define) so it is @@ -178,7 +178,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt // s_original_callback is always valid here: hook_socket() sets it before swapping // the callback pointer, so this wrapper cannot run until it's initialized. s_original_callback(conn, evt, len); - // Wake the main loop task if sleeping in ulTaskNotifyTake(). + // Wake the main loop task if sleeping in esphome_main_task_wait(). // Only notify on receive events to avoid spurious wakeups from send-ready events. // NETCONN_EVT_ERROR is deliberately omitted: LwIP signals errors via RCVPLUS // (rcvevent++ with a NULL pbuf or error in recvmbox), so error conditions @@ -186,7 +186,7 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt if (evt == NETCONN_EVT_RCVPLUS) { #ifdef USE_OTA_PLATFORM_ESPHOME // Mark OTA pending-enable only for events on its listen socket. MUST happen - // before xTaskNotifyGive so the flags are visible when the main task wakes. + // before the main task is notified so the flags are visible when it wakes. if (conn == s_ota_listener_conn) { esphome_wake_ota_component_any_context(); } diff --git a/esphome/core/lwip_fast_select.h b/esphome/core/lwip_fast_select.h index 4ba2606d76..69798fd8eb 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -33,9 +33,9 @@ struct lwip_sock *esphome_lwip_get_sock(int fd); /// per-call `memw` that volatile would emit on Xtensa under default /// -mserialize-volatile. Without atomics (e.g. BK72xx), the fence is skipped /// and the volatile load provides ordering on its own. -/// Stale reads are harmless either way: the hooked event_callback -/// xTaskNotifyGives on RCVPLUS, so the next iteration re-snapshots and -/// ulTaskNotifyTake never loses a wake. +/// Stale reads are harmless either way: the hooked event_callback calls +/// esphome_main_task_notify() on RCVPLUS, so the next iteration re-snapshots and +/// esphome_main_task_wait() never loses a wake. /// The offset and size are verified at compile time in lwip_fast_select.c. static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { #ifdef ESPHOME_THREAD_MULTI_ATOMICS @@ -46,7 +46,7 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) { } /// Hook a socket's netconn callback to notify the main loop task on receive events. -/// Wraps the original event_callback with one that also calls xTaskNotifyGive(). +/// Wraps the original event_callback with one that also calls esphome_main_task_notify(). /// Must be called from the main loop after socket creation. /// The sock pointer must have been obtained from esphome_lwip_get_sock(). void esphome_lwip_hook_socket(struct lwip_sock *sock); diff --git a/esphome/core/main_task.h b/esphome/core/main_task.h index 3aa8669e44..836d13d8f7 100644 --- a/esphome/core/main_task.h +++ b/esphome/core/main_task.h @@ -19,12 +19,25 @@ extern "C" { extern TaskHandle_t esphome_main_task_handle; +#ifdef USE_ESP32 +/// ESP-IDF waits on index 0 from the main task (e.g. pthread_join() uses an unfiltered +/// xTaskNotifyWait), so main-loop wakes on index 0 would end those waits early. +enum { ESPHOME_MAIN_TASK_NOTIFY_INDEX = 1 }; +#if configTASK_NOTIFICATION_ARRAY_ENTRIES < 2 +#error "CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES must be at least 2" +#endif +#endif + /// Wake the main loop task from another FreeRTOS task. NOT ISR-safe. /// always_inline so callers placed in IRAM do not reference a flash-resident copy. __attribute__((always_inline)) static inline void esphome_main_task_notify() { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { +#ifdef USE_ESP32 + xTaskNotifyGiveIndexed(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX); +#else xTaskNotifyGive(task); +#endif } } @@ -33,10 +46,23 @@ __attribute__((always_inline)) static inline void esphome_main_task_notify_from_ BaseType_t *px_higher_priority_task_woken) { TaskHandle_t task = esphome_main_task_handle; if (task != NULL) { +#ifdef USE_ESP32 + vTaskNotifyGiveIndexedFromISR(task, ESPHOME_MAIN_TASK_NOTIFY_INDEX, px_higher_priority_task_woken); +#else vTaskNotifyGiveFromISR(task, px_higher_priority_task_woken); +#endif } } +/// Block the main loop task until it is woken or the timeout expires. +__attribute__((always_inline)) static inline void esphome_main_task_wait(TickType_t ticks) { +#ifdef USE_ESP32 + ulTaskNotifyTakeIndexed(ESPHOME_MAIN_TASK_NOTIFY_INDEX, pdTRUE, ticks); +#else + ulTaskNotifyTake(pdTRUE, ticks); +#endif +} + #ifdef __cplusplus } #endif diff --git a/esphome/core/millis_internal.h b/esphome/core/millis_internal.h index 7297d22357..c9b8454078 100644 --- a/esphome/core/millis_internal.h +++ b/esphome/core/millis_internal.h @@ -51,6 +51,7 @@ class MillisInternal { } friend class Application; friend class LoopBlockingGuard; + friend class UnavoidableBlockingScope; }; } // 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/ring_buffer.h b/esphome/core/ring_buffer.h deleted file mode 100644 index fbb089e906..0000000000 --- a/esphome/core/ring_buffer.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -#ifdef USE_ESP32 - -// Deprecated: include "esphome/components/ring_buffer/ring_buffer.h" and use -// esphome::ring_buffer::RingBuffer. This shim will be removed in 2026.11.0. -#if __has_include("esphome/components/ring_buffer/ring_buffer.h") -#include "esphome/components/ring_buffer/ring_buffer.h" -#else -#error \ - "esphome/components/ring_buffer/ring_buffer.h not found. Add 'ring_buffer' to your component's AUTO_LOAD list to use esphome::ring_buffer::RingBuffer." -#endif -#include "esphome/core/helpers.h" // for ESPDEPRECATED - -namespace esphome { - -using RingBuffer ESPDEPRECATED("Use esphome::ring_buffer::RingBuffer instead. Removed in 2026.11.0.", - "2026.5.0") = ring_buffer::RingBuffer; - -} // namespace esphome - -#endif diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index afb323f78e..5d0b96c38f 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_. @@ -138,79 +138,84 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type } // Take lock early to protect scheduler_item_pool_head_ access - LockGuard guard{this->lock_}; + { + LockGuard guard{this->lock_}; - // Create and populate the scheduler item - SchedulerItem *item = this->get_item_from_pool_locked_(); - // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. - if (name_type == NameType::SELF_POINTER) { - item->source_name = source; - } else { - item->component = component; - } - item->set_name(name_type, static_name, hash_or_id); - item->type = type; - // Use destroy + placement-new instead of move-assignment. - // GCC's std::function::operator=(function&&) does a full swap dance even when the - // target is empty. Since recycled/new items always have an empty callback, we can - // destroy the empty one (no-op) and move-construct directly, saving ~40 bytes of - // swap/destructor code on Xtensa. - item->callback.~function(); - new (&item->callback) std::function(std::move(func)); - // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use - this->set_item_removed_(item, false); + // Create and populate the scheduler item + SchedulerItem *item = this->get_item_from_pool_locked_(); + // SELF_POINTER items store the source name (owning script) in the union slot instead of a component. + if (name_type == NameType::SELF_POINTER) { + item->source_name = source; + } else { + item->component = component; + } + item->set_name(name_type, static_name, hash_or_id); + item->type = type; + // Use destroy + placement-new instead of move-assignment. + // GCC's std::function::operator=(function&&) does a full swap dance even when the + // target is empty. Since recycled/new items always have an empty callback, we can + // destroy the empty one (no-op) and move-construct directly, saving ~40 bytes of + // swap/destructor code on Xtensa. + item->callback.~function(); + new (&item->callback) std::function(std::move(func)); + // Reset remove flag - recycled items may have been cancelled (remove=true) in previous use + this->set_item_removed_(item, false); - // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. - // Using a pointer lets both paths share the cancel + push_back epilogue. - auto *target = &this->to_add_; + // Determine target container: defer_queue_ for deferred items, to_add_ for everything else. + // Using a pointer lets both paths share the cancel + push_back epilogue. + auto *target = &this->to_add_; #ifndef ESPHOME_THREAD_SINGLE - // Special handling for defer() (delay = 0, type = TIMEOUT) - // Single-core platforms don't need thread-safe defer handling - if (delay == 0 && type == SchedulerItem::TIMEOUT) { - // Put in defer queue for guaranteed FIFO execution - target = &this->defer_queue_; - } else + // Special handling for defer() (delay = 0, type = TIMEOUT) + // Single-core platforms don't need thread-safe defer handling + if (delay == 0 && type == SchedulerItem::TIMEOUT) { + // Put in defer queue for guaranteed FIFO execution + target = &this->defer_queue_; + } else #endif /* not ESPHOME_THREAD_SINGLE */ - { - // Only non-defer items need a timestamp for scheduling - const uint64_t now_64 = millis_64(); + { + // Only non-defer items need a timestamp for scheduling + const uint64_t now_64 = millis_64(); - // Type-specific setup - if (type == SchedulerItem::INTERVAL) { - item->interval = delay; - // first execution happens immediately after a random smallish offset - uint32_t offset = this->calculate_interval_offset_(delay); - item->set_next_execution(now_64 + offset); + // Type-specific setup + if (type == SchedulerItem::INTERVAL) { + item->interval = delay; + // first execution happens immediately after a random smallish offset + uint32_t offset = this->calculate_interval_offset_(delay); + item->set_next_execution(now_64 + offset); #ifdef ESPHOME_LOG_HAS_VERBOSE - SchedulerNameLog name_log; - ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", - name_log.format(name_type, static_name, hash_or_id), delay, offset); + SchedulerNameLog name_log; + ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms", + name_log.format(name_type, static_name, hash_or_id), delay, offset); #endif - } else { - item->interval = 0; - item->set_next_execution(now_64 + delay); - } + } else { + item->interval = 0; + item->set_next_execution(now_64 + delay); + } #ifdef ESPHOME_DEBUG_SCHEDULER - this->debug_log_timer_(item, name_type, static_name, hash_or_id, delay, now_64); + this->debug_log_timer_(item, name_type, static_name, hash_or_id, delay, now_64); #endif /* ESPHOME_DEBUG_SCHEDULER */ - } + } - // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) - // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. - if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { - this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); - } - target->push_back(item); - if (target == &this->to_add_) { - this->to_add_count_increment_locked_(); - } + // Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous) + // Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan. + if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) { + this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* find_first= */ true); + } + target->push_back(item); + if (target == &this->to_add_) { + this->to_add_count_increment_locked_(); + } #ifndef ESPHOME_THREAD_SINGLE - else { - this->defer_count_increment_locked_(); - } + else { + this->defer_count_increment_locked_(); + } #endif + } + // A background insertion may shorten a sleep whose deadline was already computed. + if (!is_main_loop_thread()) [[unlikely]] + wake_scheduler_threadsafe(); } void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout, 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..1427541229 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 @@ -257,8 +272,23 @@ inline double stod(const StringRef &str, size_t *pos = nullptr) { // NOLINTEND(readability-identifier-naming,google-runtime-int,readability-non-const-parameter) #ifdef USE_JSON +// A StringRef may view a scratch buffer (mqtt topics do), so the document copies it // 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)); +} + +namespace json { +// For a StringRef that outlives the document, see json_util.h. A null, empty view links "" too +inline JsonString linked(const StringRef &s) { return JsonString(s.empty() ? "" : s.c_str(), s.size(), true); } +} // namespace json #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/core/wake/wake_esp8266.h b/esphome/core/wake/wake_esp8266.h index 73b7a38a35..2f7ffc292f 100644 --- a/esphome/core/wake/wake_esp8266.h +++ b/esphome/core/wake/wake_esp8266.h @@ -11,10 +11,7 @@ namespace esphome { /// Inline implementation — IRAM callers inline this directly. -inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { - // Set the wake-requested flag BEFORE esp_schedule so the consumer is - // guaranteed to see it on its next gate check. - wake_request_set(); +inline void ESPHOME_ALWAYS_INLINE wake_scheduler_impl() { // Skip the post when a wake was already signalled and not yet consumed by // wakeable_delay(): esp_schedule() -> ets_post() can enter SDK WiFi pm code, // which must not be poked per-byte from the software serial RX ISR (see @@ -26,11 +23,19 @@ inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { esp_schedule(); } +inline void ESPHOME_ALWAYS_INLINE wake_loop_impl() { + // Set the wake-requested flag BEFORE esp_schedule so the consumer is + // guaranteed to see it on its next gate check. + wake_request_set(); + wake_scheduler_impl(); +} + /// IRAM_ATTR entry point for ISR callers — defined in wake_esp8266.cpp. void wake_loop_any_context(); /// Non-ISR: always inline. inline void wake_loop_threadsafe() { wake_loop_impl(); } +inline void wake_scheduler_threadsafe() { wake_scheduler_impl(); } /// ISR-safe: no task_woken arg because ESP8266 has no FreeRTOS. Caller must be IRAM_ATTR. inline void ESPHOME_ALWAYS_INLINE wake_loop_isrsafe() { wake_loop_impl(); } diff --git a/esphome/core/wake/wake_freertos.cpp b/esphome/core/wake/wake_freertos.cpp index 458ef51f89..02c628718e 100644 --- a/esphome/core/wake/wake_freertos.cpp +++ b/esphome/core/wake/wake_freertos.cpp @@ -30,9 +30,6 @@ void IRAM_ATTR wake_loop_any_context() { wake_main_task_any_context(); } } // namespace esphome -extern "C" void esphome_wake_loop_threadsafe() { - esphome::wake_request_set(); - esphome_main_task_notify(); -} +extern "C" void esphome_wake_loop_threadsafe() { esphome::wake_loop_threadsafe(); } #endif // USE_ESP32 || USE_LIBRETINY diff --git a/esphome/core/wake/wake_freertos.h b/esphome/core/wake/wake_freertos.h index 167a422c61..1df7d38765 100644 --- a/esphome/core/wake/wake_freertos.h +++ b/esphome/core/wake/wake_freertos.h @@ -34,24 +34,26 @@ __attribute__((always_inline)) inline void wake_main_task_any_context() { void wake_loop_isrsafe(BaseType_t *px_higher_priority_task_woken); void wake_loop_any_context(); +inline void wake_scheduler_threadsafe() { esphome_main_task_notify(); } + inline void wake_loop_threadsafe() { wake_request_set(); - esphome_main_task_notify(); + wake_scheduler_threadsafe(); } namespace internal { inline void ESPHOME_ALWAYS_INLINE wakeable_delay(uint32_t ms) { // Fast path (with USE_LWIP_FAST_SELECT): FreeRTOS task notifications posted by the lwip // event_callback wrapper (see lwip_fast_select.c) are the single source of truth for - // socket wake-ups. Every NETCONN_EVT_RCVPLUS posts an xTaskNotifyGive, so any notification - // that lands between wakes keeps the counter non-zero (next ulTaskNotifyTake returns - // immediately) or wakes a blocked Take directly. Additional wake sources: + // socket wake-ups. Every NETCONN_EVT_RCVPLUS calls esphome_main_task_notify(), so any notification + // that lands between wakes keeps the counter non-zero (next esphome_main_task_wait() returns + // immediately) or wakes a blocked wait directly. Additional wake sources: // wake_loop_threadsafe() from background tasks, and the ms timeout. if (ms == 0) [[unlikely]] { yield(); return; } - ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ms)); + esphome_main_task_wait(pdMS_TO_TICKS(ms)); } } // namespace internal diff --git a/esphome/core/wake/wake_host.cpp b/esphome/core/wake/wake_host.cpp index 8cb382a77e..9fc1c71703 100644 --- a/esphome/core/wake/wake_host.cpp +++ b/esphome/core/wake/wake_host.cpp @@ -123,13 +123,12 @@ void wakeable_delay(uint32_t ms) { if (ms == 0) [[unlikely]] { yield(); } - // A socket woke select() early — open the component-phase gate so the - // owning component's loop() drains the data on this tick rather than - // waiting up to loop_interval_ ms. Idempotent if wake_loop_threadsafe() - // already set the flag (wake socket fired); required when an application - // socket fired and nothing else set the flag. + // Application sockets need the component phase to drain queued work. + // The internal wake socket may only be signaling new scheduler work. if (ret > 0) { - wake_request_set(); + const bool only_wake_socket = ret == 1 && g_wake_socket_fd >= 0 && FD_ISSET(g_wake_socket_fd, &g_read_fds); + if (!only_wake_socket) + wake_request_set(); } return; } @@ -146,16 +145,20 @@ void wakeable_delay(uint32_t ms) { } } // namespace internal -void wake_loop_threadsafe() { - // Set flag before sending so the consumer's gate check on the next loop() - // entry observes the wake regardless of select() scheduling. - wake_request_set(); +void wake_scheduler_threadsafe() { if (internal::g_wake_socket_fd >= 0) { const char dummy = 1; ::send(internal::g_wake_socket_fd, &dummy, 1, 0); } } +void wake_loop_threadsafe() { + // Set flag before sending so the consumer's gate check on the next loop() + // entry observes the wake regardless of select() scheduling. + wake_request_set(); + wake_scheduler_threadsafe(); +} + void wake_setup() { // Create UDP socket for wake notifications. internal::g_wake_socket_fd = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); diff --git a/esphome/core/wake/wake_host.h b/esphome/core/wake/wake_host.h index 9756ed4c39..8c58797df2 100644 --- a/esphome/core/wake/wake_host.h +++ b/esphome/core/wake/wake_host.h @@ -13,6 +13,7 @@ namespace esphome { /// Host: wakes select() via UDP loopback socket. Defined in wake_host.cpp. void wake_loop_threadsafe(); +void wake_scheduler_threadsafe(); /// Register a socket file descriptor with the host select() loop. Not /// thread-safe — main loop only. Returns false if fd is invalid or diff --git a/esphome/core/wake/wake_rp2.h b/esphome/core/wake/wake_rp2.h index 715e5aca0c..4f5439dbb8 100644 --- a/esphome/core/wake/wake_rp2.h +++ b/esphome/core/wake/wake_rp2.h @@ -11,12 +11,16 @@ namespace esphome { +inline void wake_scheduler_threadsafe() { + g_main_loop_woke = true; + __sev(); +} + inline void wake_loop_any_context() { // Set the wake-requested flag BEFORE the SEV so the consumer is guaranteed // to see it on its next gate check. wake_request_set(); - g_main_loop_woke = true; - __sev(); + wake_scheduler_threadsafe(); } inline void wake_loop_threadsafe() { wake_loop_any_context(); } diff --git a/esphome/core/wake/wake_zephyr.cpp b/esphome/core/wake/wake_zephyr.cpp index 577d53f5d9..2c46fbc951 100644 --- a/esphome/core/wake/wake_zephyr.cpp +++ b/esphome/core/wake/wake_zephyr.cpp @@ -21,9 +21,11 @@ K_SEM_DEFINE(esphome_wake_sem, 0, 1); // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) volatile uint8_t g_wake_requested = 0; +void wake_scheduler_threadsafe() { k_sem_give(&esphome_wake_sem); } + void wake_loop_threadsafe() { wake_request_set(); - k_sem_give(&esphome_wake_sem); + wake_scheduler_threadsafe(); } namespace internal { diff --git a/esphome/core/wake/wake_zephyr.h b/esphome/core/wake/wake_zephyr.h index c89cfc68e9..04cbfb230b 100644 --- a/esphome/core/wake/wake_zephyr.h +++ b/esphome/core/wake/wake_zephyr.h @@ -11,6 +11,7 @@ namespace esphome { /// Zephyr: wakes the main loop via k_sem_give(). Thread- and ISR-safe. /// Defined in wake_zephyr.cpp. void wake_loop_threadsafe(); +void wake_scheduler_threadsafe(); inline void wake_loop_any_context() { wake_loop_threadsafe(); } diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index a681583053..1196669662 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -10,6 +10,7 @@ from esphome.core import ( ID, Define, EnumValue, + EsphomeError, HexInt, Lambda, Library, @@ -19,7 +20,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 @@ -264,6 +270,13 @@ class FlashStringLiteral(Literal): return f"ESPHOME_F({cpp_string_escape(self.string)})" +def progmem_string(value: str) -> Expression: + """A ``std::string`` argument from a literal that stays in flash on ESP8266.""" + if CORE.is_esp8266: + return RawExpression(f"progmem_string({FlashStringLiteral(value)})") + return safe_exp(value) + + class IntLiteral(Literal): __slots__ = ("i",) @@ -435,13 +448,16 @@ class LineComment(Statement): class ProgmemAssignmentExpression(AssignmentExpression): - __slots__ = () + __slots__ = ("constexpr",) - def __init__(self, type_, name, rhs): + def __init__(self, type_, name, rhs, constexpr: bool = True): super().__init__(type_, "", name, rhs) + self.constexpr = constexpr def __str__(self): - return f"static constexpr {self.type} {self.name}[] PROGMEM = {self.rhs}" + if self.constexpr: + return f"static constexpr {self.type} {self.name}[] PROGMEM = {self.rhs}" + return f"ESPHOME_FLASH_CONSTINIT static {self.type} const {self.name}[] PROGMEM = {self.rhs}" class StaticConstAssignmentExpression(AssignmentExpression): @@ -463,6 +479,80 @@ 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, + constexpr: bool = True, +) -> "MockObj": + """Emit a global PROGMEM array once per distinct type and contents; later calls reuse it. + + The array is ``static constexpr`` by default, 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. + ``constexpr=False`` is for tables of generated object pointers: each top level variable + element must pass ``is_static_pointer`` or ``EsphomeError`` is raised. + """ + 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) + if not constexpr and isinstance(rhs, ArrayInitializer): + for arg in rhs.args: + if isinstance(arg, MockObj) and not is_static_pointer(arg): + raise EsphomeError( + f"'{arg}' must be created with cg.new_Pvariable so its address is known " + "at compile time" + ) + key = f"{type_} {rhs} {constexpr}" + 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, constexpr)) + array = MockObj(id_, ".") + CORE.register_variable(id_, array) + if share: + arrays[key] = array + return array + + +def shared_progmem_string(value: str) -> Expression: + """Return a string that stays in flash: one shared PROGMEM array per text on ESP8266. + + Other platforms keep string literals in flash already, so they get the plain literal. + """ + from esphome.cpp_types import char + + text = StringLiteral(value) + if CORE.is_esp8266: + return shared_progmem_array("progmem_str", char, text) + return text + + def static_const_array(id_, rhs) -> "MockObj": rhs = safe_exp(rhs) obj = MockObj(id_, ".") @@ -644,6 +734,7 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": ) placement_new = CallExpression(f"new({id_.id}) {actual_type}", *call_expr.args) CORE.add(ExpressionStatement(placement_new)) + CORE.data.setdefault(_STATIC_POINTER_IDS, set()).add(id_.id) else: decl = VariableDeclarationExpression(id_.type, "*", id_, static=True) CORE.add_global(decl) @@ -653,6 +744,20 @@ def Pvariable(id_: ID, rhs: SafeExpType, type_: "MockObj" = None) -> "MockObj": return obj +_STATIC_POINTER_IDS = "static_pointer_ids" + + +def is_static_pointer(obj: SafeExpType) -> bool: + """True if ``obj`` is a Pvariable whose object was placement constructed in static storage. + + Its pointer is then an address known at compile time and may appear in a ``constexpr=False`` + PROGMEM table; a pointer assigned in ``setup()`` may not. + """ + return isinstance(obj, MockObj) and str(obj.base) in CORE.data.get( + _STATIC_POINTER_IDS, () + ) + + def new_Pvariable(id_: ID, *args: SafeExpType) -> "MockObj": """Declare a new pointer variable in the code generation by calling it's constructor with the given arguments. @@ -670,6 +775,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. 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